@grafema/mcp 0.1.0-alpha.5 → 0.1.1-alpha
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/analysis-worker.js +5 -1
- package/dist/analysis.d.ts +12 -2
- package/dist/analysis.d.ts.map +1 -1
- package/dist/analysis.js +50 -31
- package/dist/config.d.ts +12 -13
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -57
- package/dist/handlers.d.ts.map +1 -1
- package/dist/handlers.js +40 -11
- package/dist/state.d.ts +39 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +115 -0
- package/dist/types.d.ts +3 -8
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -4
- package/src/analysis-worker.ts +6 -1
- package/src/analysis.ts +61 -33
- package/src/config.ts +32 -71
- package/src/handlers.ts +46 -13
- package/src/state.ts +127 -0
- package/src/types.ts +3 -8
package/dist/analysis-worker.js
CHANGED
|
@@ -131,7 +131,11 @@ async function run() {
|
|
|
131
131
|
console.log(`[Worker] Connecting to RFDB server: socket=${socketPath}, db=${dbPath}`);
|
|
132
132
|
db = new RFDBServerBackend({ socketPath, dbPath });
|
|
133
133
|
await db.connect();
|
|
134
|
-
|
|
134
|
+
// NOTE: db.clear() is NOT called here.
|
|
135
|
+
// MCP server clears DB INSIDE the analysis lock BEFORE spawning this worker.
|
|
136
|
+
// This prevents race conditions where concurrent analysis calls could both
|
|
137
|
+
// clear the database. Worker assumes DB is already clean.
|
|
138
|
+
// See: REG-159 implementation, Phase 2.5 (Worker Clear Coordination)
|
|
135
139
|
sendProgress({ phase: 'discovery', message: 'Starting analysis...' });
|
|
136
140
|
// Create orchestrator
|
|
137
141
|
const orchestrator = new Orchestrator({
|
package/dist/analysis.d.ts
CHANGED
|
@@ -3,9 +3,19 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import type { GraphBackend } from '@grafema/types';
|
|
5
5
|
/**
|
|
6
|
-
* Ensure project is analyzed, optionally filtering to a single service
|
|
6
|
+
* Ensure project is analyzed, optionally filtering to a single service.
|
|
7
|
+
*
|
|
8
|
+
* CONCURRENCY: This function is protected by a global mutex.
|
|
9
|
+
* - Only one analysis can run at a time
|
|
10
|
+
* - Concurrent calls wait for the current analysis to complete
|
|
11
|
+
* - force=true while analysis is running returns an error immediately
|
|
12
|
+
*
|
|
13
|
+
* @param serviceName - Optional service to analyze (null = all)
|
|
14
|
+
* @param force - If true, clear DB and re-analyze even if already analyzed.
|
|
15
|
+
* ERROR if another analysis is already running.
|
|
16
|
+
* @throws Error if force=true and analysis is already running
|
|
7
17
|
*/
|
|
8
|
-
export declare function ensureAnalyzed(serviceName?: string | null): Promise<GraphBackend>;
|
|
18
|
+
export declare function ensureAnalyzed(serviceName?: string | null, force?: boolean): Promise<GraphBackend>;
|
|
9
19
|
/**
|
|
10
20
|
* Discover services without running full analysis
|
|
11
21
|
*/
|
package/dist/analysis.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"analysis.d.ts","sourceRoot":"","sources":["../src/analysis.ts"],"names":[],"mappings":"AAAA;;GAEG;
|
|
1
|
+
{"version":3,"file":"analysis.d.ts","sourceRoot":"","sources":["../src/analysis.ts"],"names":[],"mappings":"AAAA;;GAEG;AAeH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAEnD;;;;;;;;;;;;GAYG;AACH,wBAAsB,cAAc,CAClC,WAAW,GAAE,MAAM,GAAG,IAAW,EACjC,KAAK,GAAE,OAAe,GACrB,OAAO,CAAC,YAAY,CAAC,CAwGvB;AAED;;GAEG;AACH,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC,CAyC3D"}
|
package/dist/analysis.js
CHANGED
|
@@ -2,42 +2,57 @@
|
|
|
2
2
|
* MCP Analysis Orchestration
|
|
3
3
|
*/
|
|
4
4
|
import { Orchestrator } from '@grafema/core';
|
|
5
|
-
import { getOrCreateBackend, getProjectPath, getIsAnalyzed, setIsAnalyzed, getAnalysisStatus, setAnalysisStatus, } from './state.js';
|
|
6
|
-
import { loadConfig, loadCustomPlugins,
|
|
5
|
+
import { getOrCreateBackend, getProjectPath, getIsAnalyzed, setIsAnalyzed, getAnalysisStatus, setAnalysisStatus, isAnalysisRunning, acquireAnalysisLock, } from './state.js';
|
|
6
|
+
import { loadConfig, loadCustomPlugins, createPlugins } from './config.js';
|
|
7
7
|
import { log } from './utils.js';
|
|
8
8
|
/**
|
|
9
|
-
* Ensure project is analyzed, optionally filtering to a single service
|
|
9
|
+
* Ensure project is analyzed, optionally filtering to a single service.
|
|
10
|
+
*
|
|
11
|
+
* CONCURRENCY: This function is protected by a global mutex.
|
|
12
|
+
* - Only one analysis can run at a time
|
|
13
|
+
* - Concurrent calls wait for the current analysis to complete
|
|
14
|
+
* - force=true while analysis is running returns an error immediately
|
|
15
|
+
*
|
|
16
|
+
* @param serviceName - Optional service to analyze (null = all)
|
|
17
|
+
* @param force - If true, clear DB and re-analyze even if already analyzed.
|
|
18
|
+
* ERROR if another analysis is already running.
|
|
19
|
+
* @throws Error if force=true and analysis is already running
|
|
10
20
|
*/
|
|
11
|
-
export async function ensureAnalyzed(serviceName = null) {
|
|
21
|
+
export async function ensureAnalyzed(serviceName = null, force = false) {
|
|
12
22
|
const db = await getOrCreateBackend();
|
|
13
23
|
const projectPath = getProjectPath();
|
|
14
|
-
|
|
15
|
-
|
|
24
|
+
// CONCURRENCY CHECK: If force=true and analysis is running, error immediately
|
|
25
|
+
// This check is BEFORE acquiring lock to fail fast
|
|
26
|
+
if (force && isAnalysisRunning()) {
|
|
27
|
+
throw new Error('Analysis is already in progress. Cannot force re-analysis while another analysis is running. ' +
|
|
28
|
+
'Wait for the current analysis to complete or check status with get_analysis_status.');
|
|
29
|
+
}
|
|
30
|
+
// Skip if already analyzed (and not forcing, and no service filter)
|
|
31
|
+
if (getIsAnalyzed() && !serviceName && !force) {
|
|
32
|
+
return db;
|
|
33
|
+
}
|
|
34
|
+
// Acquire lock (waits if another analysis is running)
|
|
35
|
+
const releaseLock = await acquireAnalysisLock();
|
|
36
|
+
try {
|
|
37
|
+
// Double-check after acquiring lock (another call might have completed analysis while we waited)
|
|
38
|
+
if (getIsAnalyzed() && !serviceName && !force) {
|
|
39
|
+
return db;
|
|
40
|
+
}
|
|
41
|
+
// Clear DB inside lock, BEFORE running analysis
|
|
42
|
+
// This is critical for worker coordination: MCP server clears DB here,
|
|
43
|
+
// worker does NOT call db.clear() (see analysis-worker.ts)
|
|
44
|
+
if (force || !getIsAnalyzed()) {
|
|
45
|
+
log('[Grafema MCP] Clearing database before analysis...');
|
|
46
|
+
if (db.clear) {
|
|
47
|
+
await db.clear();
|
|
48
|
+
}
|
|
49
|
+
setIsAnalyzed(false);
|
|
50
|
+
}
|
|
16
51
|
log(`[Grafema MCP] Analyzing project: ${projectPath}${serviceName ? ` (service: ${serviceName})` : ''}`);
|
|
17
52
|
const config = loadConfig(projectPath);
|
|
18
53
|
const { pluginMap: customPluginMap } = await loadCustomPlugins(projectPath);
|
|
19
|
-
//
|
|
20
|
-
const
|
|
21
|
-
...BUILTIN_PLUGINS,
|
|
22
|
-
...Object.fromEntries(Object.entries(customPluginMap).map(([name, PluginClass]) => [
|
|
23
|
-
name,
|
|
24
|
-
() => new PluginClass(),
|
|
25
|
-
])),
|
|
26
|
-
};
|
|
27
|
-
// Build plugin list from config
|
|
28
|
-
const plugins = [];
|
|
29
|
-
for (const [phase, pluginNames] of Object.entries(config.plugins || {})) {
|
|
30
|
-
for (const name of pluginNames) {
|
|
31
|
-
const factory = availablePlugins[name];
|
|
32
|
-
if (factory) {
|
|
33
|
-
plugins.push(factory());
|
|
34
|
-
log(`[Grafema MCP] Enabled plugin: ${name} (${phase})`);
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
log(`[Grafema MCP] Warning: Unknown plugin ${name} in config`);
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
54
|
+
// Create plugins from config
|
|
55
|
+
const plugins = createPlugins(config.plugins, customPluginMap);
|
|
41
56
|
log(`[Grafema MCP] Total plugins: ${plugins.length}`);
|
|
42
57
|
// Check for parallel analysis config
|
|
43
58
|
const parallelConfig = config.analysis?.parallel;
|
|
@@ -75,9 +90,13 @@ export async function ensureAnalyzed(serviceName = null) {
|
|
|
75
90
|
total: parseFloat(totalTime),
|
|
76
91
|
},
|
|
77
92
|
});
|
|
78
|
-
log(`[Grafema MCP]
|
|
93
|
+
log(`[Grafema MCP] Analysis complete in ${totalTime}s`);
|
|
94
|
+
return db;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
// ALWAYS release the lock, even on error
|
|
98
|
+
releaseLock();
|
|
79
99
|
}
|
|
80
|
-
return db;
|
|
81
100
|
}
|
|
82
101
|
/**
|
|
83
102
|
* Discover services without running full analysis
|
|
@@ -95,7 +114,7 @@ export async function discoverServices() {
|
|
|
95
114
|
])),
|
|
96
115
|
};
|
|
97
116
|
const plugins = [];
|
|
98
|
-
const discoveryPluginNames = config.plugins
|
|
117
|
+
const discoveryPluginNames = config.plugins.discovery ?? [];
|
|
99
118
|
for (const name of discoveryPluginNames) {
|
|
100
119
|
const factory = availablePlugins[name];
|
|
101
120
|
if (factory) {
|
package/dist/config.d.ts
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP Server Configuration
|
|
3
3
|
*/
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
discovery?:
|
|
10
|
-
}
|
|
11
|
-
export interface ProjectConfig {
|
|
12
|
-
plugins: PluginConfig;
|
|
13
|
-
discovery: {
|
|
4
|
+
import { type GrafemaConfig } from '@grafema/core';
|
|
5
|
+
/**
|
|
6
|
+
* MCP-specific configuration extends GrafemaConfig with additional fields.
|
|
7
|
+
*/
|
|
8
|
+
export interface MCPConfig extends GrafemaConfig {
|
|
9
|
+
discovery?: {
|
|
14
10
|
enabled: boolean;
|
|
15
11
|
customOnly: boolean;
|
|
16
12
|
};
|
|
@@ -20,15 +16,18 @@ export interface ProjectConfig {
|
|
|
20
16
|
backend?: 'local' | 'rfdb';
|
|
21
17
|
rfdb_socket?: string;
|
|
22
18
|
}
|
|
23
|
-
export declare const DEFAULT_CONFIG: ProjectConfig;
|
|
24
19
|
type PluginFactory = () => unknown;
|
|
25
20
|
export declare const BUILTIN_PLUGINS: Record<string, PluginFactory>;
|
|
26
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Load MCP configuration (extends base GrafemaConfig).
|
|
23
|
+
* Uses shared ConfigLoader but adds MCP-specific defaults.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadConfig(projectPath: string): MCPConfig;
|
|
27
26
|
export interface CustomPluginResult {
|
|
28
27
|
plugins: unknown[];
|
|
29
28
|
pluginMap: Record<string, new () => unknown>;
|
|
30
29
|
}
|
|
31
30
|
export declare function loadCustomPlugins(projectPath: string): Promise<CustomPluginResult>;
|
|
32
|
-
export declare function createPlugins(
|
|
31
|
+
export declare function createPlugins(config: GrafemaConfig['plugins'], customPluginMap?: Record<string, new () => unknown>): unknown[];
|
|
33
32
|
export {};
|
|
34
33
|
//# sourceMappingURL=config.d.ts.map
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;GAEG;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,OAAO,EAAoC,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAoCrF;;GAEG;AACH,MAAM,WAAW,SAAU,SAAQ,aAAa;IAC9C,SAAS,CAAC,EAAE;QACV,OAAO,EAAE,OAAO,CAAC;QACjB,UAAU,EAAE,OAAO,CAAC;KACrB,CAAC;IACF,QAAQ,CAAC,EAAE;QACT,OAAO,CAAC,EAAE,MAAM,CAAC;KAClB,CAAC;IACF,OAAO,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAUD,KAAK,aAAa,GAAG,MAAM,OAAO,CAAC;AAEnC,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAiCzD,CAAC;AAGF;;;GAGG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAWzD;AAGD,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,OAAO,EAAE,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,OAAO,CAAC,CAAC;CAC9C;AAED,wBAAsB,iBAAiB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAoCxF;AAGD,wBAAgB,aAAa,CAC3B,MAAM,EAAE,aAAa,CAAC,SAAS,CAAC,EAChC,eAAe,GAAE,MAAM,CAAC,MAAM,EAAE,UAAU,OAAO,CAAM,GACtD,OAAO,EAAE,CA8BX"}
|
package/dist/config.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* MCP Server Configuration
|
|
3
3
|
*/
|
|
4
4
|
import { join } from 'path';
|
|
5
|
-
import { existsSync,
|
|
5
|
+
import { existsSync, readdirSync } from 'fs';
|
|
6
6
|
import { pathToFileURL } from 'url';
|
|
7
7
|
import { log } from './utils.js';
|
|
8
|
+
import { loadConfig as loadConfigFromCore } from '@grafema/core';
|
|
8
9
|
// === PLUGIN IMPORTS ===
|
|
9
10
|
import {
|
|
10
11
|
// Indexing
|
|
@@ -15,35 +16,7 @@ JSASTAnalyzer, ExpressRouteAnalyzer, SocketIOAnalyzer, DatabaseAnalyzer, FetchAn
|
|
|
15
16
|
MethodCallResolver, AliasTracker, ValueDomainAnalyzer, MountPointResolver, PrefixEvaluator, InstanceOfResolver, HTTPConnectionEnricher, RustFFIEnricher,
|
|
16
17
|
// Validation
|
|
17
18
|
CallResolverValidator, EvalBanValidator, SQLInjectionValidator, ShadowingDetector, GraphConnectivityValidator, DataFlowValidator, TypeScriptDeadCodeValidator, } from '@grafema/core';
|
|
18
|
-
|
|
19
|
-
plugins: {
|
|
20
|
-
indexing: ['JSModuleIndexer'],
|
|
21
|
-
analysis: [
|
|
22
|
-
'JSASTAnalyzer',
|
|
23
|
-
'ExpressRouteAnalyzer',
|
|
24
|
-
'SocketIOAnalyzer',
|
|
25
|
-
'DatabaseAnalyzer',
|
|
26
|
-
'FetchAnalyzer',
|
|
27
|
-
'ServiceLayerAnalyzer',
|
|
28
|
-
],
|
|
29
|
-
enrichment: [
|
|
30
|
-
'MethodCallResolver',
|
|
31
|
-
'AliasTracker',
|
|
32
|
-
'ValueDomainAnalyzer',
|
|
33
|
-
'MountPointResolver',
|
|
34
|
-
'PrefixEvaluator',
|
|
35
|
-
'HTTPConnectionEnricher',
|
|
36
|
-
],
|
|
37
|
-
validation: [
|
|
38
|
-
'CallResolverValidator',
|
|
39
|
-
'EvalBanValidator',
|
|
40
|
-
'SQLInjectionValidator',
|
|
41
|
-
'ShadowingDetector',
|
|
42
|
-
'GraphConnectivityValidator',
|
|
43
|
-
'DataFlowValidator',
|
|
44
|
-
'TypeScriptDeadCodeValidator',
|
|
45
|
-
],
|
|
46
|
-
},
|
|
19
|
+
const MCP_DEFAULTS = {
|
|
47
20
|
discovery: {
|
|
48
21
|
enabled: true,
|
|
49
22
|
customOnly: false,
|
|
@@ -81,33 +54,20 @@ export const BUILTIN_PLUGINS = {
|
|
|
81
54
|
TypeScriptDeadCodeValidator: () => new TypeScriptDeadCodeValidator(),
|
|
82
55
|
};
|
|
83
56
|
// === CONFIG LOADING ===
|
|
57
|
+
/**
|
|
58
|
+
* Load MCP configuration (extends base GrafemaConfig).
|
|
59
|
+
* Uses shared ConfigLoader but adds MCP-specific defaults.
|
|
60
|
+
*/
|
|
84
61
|
export function loadConfig(projectPath) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
log(`[Grafema MCP] Created default config: ${configPath}`);
|
|
95
|
-
}
|
|
96
|
-
catch (err) {
|
|
97
|
-
log(`[Grafema MCP] Failed to create config: ${err.message}`);
|
|
98
|
-
}
|
|
99
|
-
return DEFAULT_CONFIG;
|
|
100
|
-
}
|
|
101
|
-
try {
|
|
102
|
-
const configContent = readFileSync(configPath, 'utf-8');
|
|
103
|
-
const config = JSON.parse(configContent);
|
|
104
|
-
log(`[Grafema MCP] Loaded config from ${configPath}`);
|
|
105
|
-
return { ...DEFAULT_CONFIG, ...config };
|
|
106
|
-
}
|
|
107
|
-
catch (err) {
|
|
108
|
-
log(`[Grafema MCP] Failed to load config: ${err.message}, using defaults`);
|
|
109
|
-
return DEFAULT_CONFIG;
|
|
110
|
-
}
|
|
62
|
+
// Use shared loader (handles YAML/JSON, deprecation warnings)
|
|
63
|
+
const baseConfig = loadConfigFromCore(projectPath, {
|
|
64
|
+
warn: (msg) => log(`[Grafema MCP] ${msg}`),
|
|
65
|
+
});
|
|
66
|
+
// Add MCP-specific defaults
|
|
67
|
+
return {
|
|
68
|
+
...baseConfig,
|
|
69
|
+
...MCP_DEFAULTS,
|
|
70
|
+
};
|
|
111
71
|
}
|
|
112
72
|
export async function loadCustomPlugins(projectPath) {
|
|
113
73
|
const pluginsDir = join(projectPath, '.grafema', 'plugins');
|
|
@@ -142,7 +102,13 @@ export async function loadCustomPlugins(projectPath) {
|
|
|
142
102
|
return { plugins: customPlugins, pluginMap };
|
|
143
103
|
}
|
|
144
104
|
// === PLUGIN INSTANTIATION ===
|
|
145
|
-
export function createPlugins(
|
|
105
|
+
export function createPlugins(config, customPluginMap = {}) {
|
|
106
|
+
const pluginNames = [
|
|
107
|
+
...config.indexing,
|
|
108
|
+
...config.analysis,
|
|
109
|
+
...config.enrichment,
|
|
110
|
+
...config.validation,
|
|
111
|
+
];
|
|
146
112
|
const plugins = [];
|
|
147
113
|
const availablePlugins = {
|
|
148
114
|
...BUILTIN_PLUGINS,
|
package/dist/handlers.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../src/handlers.ts"],"names":[],"mappings":"AAAA;;GAEG;
|
|
1
|
+
{"version":3,"file":"handlers.d.ts","sourceRoot":"","sources":["../src/handlers.ts"],"names":[],"mappings":"AAAA;;GAEG;AAeH,OAAO,KAAK,EACV,UAAU,EACV,cAAc,EACd,aAAa,EACb,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,eAAe,EACf,oBAAoB,EAGrB,MAAM,YAAY,CAAC;AAKpB,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA8EhF;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,UAAU,CAAC,CA2E9E;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,UAAU,CAAC,CAiD9E;AAID,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA2DhF;AAED,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,UAAU,CAAC,CA+DtF;AAED,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,UAAU,CAAC,CA4CxF;AAID,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC,UAAU,CAAC,CA4BxF;AAED,wBAAsB,uBAAuB,IAAI,OAAO,CAAC,UAAU,CAAC,CAYnE;AAED,wBAAsB,cAAc,IAAI,OAAO,CAAC,UAAU,CAAC,CAe1D;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,UAAU,CAAC,CAwB9E;AAID;;GAEG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,UAAU,CAAC,CA6D1F;AAED;;GAEG;AACH,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,UAAU,CAAC,CAyChE;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,UAAU,CAAC,CAwG1F;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,mBAAmB,GAAG,OAAO,CAAC,UAAU,CAAC,CA8B1F;AAID,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,UAAU,CAAC,CAqClF;AAED,wBAAsB,sBAAsB,CAAC,IAAI,EAAE,oBAAoB,GAAG,OAAO,CAAC,UAAU,CAAC,CAwE5F;AAID,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,OAAO,YAAY,EAAE,eAAe,GAAG,OAAO,CAAC,UAAU,CAAC,CAkEvG"}
|
package/dist/handlers.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* MCP Tool Handlers
|
|
3
3
|
*/
|
|
4
4
|
import { ensureAnalyzed } from './analysis.js';
|
|
5
|
-
import { getProjectPath, getAnalysisStatus,
|
|
5
|
+
import { getProjectPath, getAnalysisStatus, getOrCreateBackend, getGuaranteeManager, getGuaranteeAPI, isAnalysisRunning } from './state.js';
|
|
6
|
+
import { CoverageAnalyzer } from '@grafema/core';
|
|
6
7
|
import { normalizeLimit, formatPaginationInfo, guardResponseSize, serializeBigInt, findSimilarTypes, textResult, errorResult, } from './utils.js';
|
|
7
8
|
import { isGuaranteeType } from '@grafema/core';
|
|
8
9
|
// === QUERY HANDLERS ===
|
|
@@ -174,7 +175,7 @@ export async function handleFindNodes(args) {
|
|
|
174
175
|
// === TRACE HANDLERS ===
|
|
175
176
|
export async function handleTraceAlias(args) {
|
|
176
177
|
const db = await ensureAnalyzed();
|
|
177
|
-
const {
|
|
178
|
+
const { variableName, file } = args;
|
|
178
179
|
const projectPath = getProjectPath();
|
|
179
180
|
let varNode = null;
|
|
180
181
|
for await (const node of db.queryNodes({ type: 'VARIABLE' })) {
|
|
@@ -306,13 +307,18 @@ export async function handleCheckInvariant(args) {
|
|
|
306
307
|
// === ANALYSIS HANDLERS ===
|
|
307
308
|
export async function handleAnalyzeProject(args) {
|
|
308
309
|
const { service, force } = args;
|
|
309
|
-
|
|
310
|
-
|
|
310
|
+
// Early check: return error for force=true if analysis is already running
|
|
311
|
+
// This provides immediate feedback instead of waiting or causing corruption
|
|
312
|
+
if (force && isAnalysisRunning()) {
|
|
313
|
+
return errorResult('Cannot force re-analysis: analysis is already in progress. ' +
|
|
314
|
+
'Use get_analysis_status to check current status, or wait for completion.');
|
|
311
315
|
}
|
|
316
|
+
// Note: setIsAnalyzed(false) is now handled inside ensureAnalyzed() within the lock
|
|
317
|
+
// to prevent race conditions where multiple calls could both clear the database
|
|
312
318
|
try {
|
|
313
|
-
await ensureAnalyzed(service || null);
|
|
319
|
+
await ensureAnalyzed(service || null, force || false);
|
|
314
320
|
const status = getAnalysisStatus();
|
|
315
|
-
return textResult(
|
|
321
|
+
return textResult(`Analysis complete!\n` +
|
|
316
322
|
`- Services discovered: ${status.servicesDiscovered}\n` +
|
|
317
323
|
`- Services analyzed: ${status.servicesAnalyzed}\n` +
|
|
318
324
|
`- Total time: ${status.timings.total || 'N/A'}s`);
|
|
@@ -608,11 +614,34 @@ export async function handleGetCoverage(args) {
|
|
|
608
614
|
const db = await getOrCreateBackend();
|
|
609
615
|
const projectPath = getProjectPath();
|
|
610
616
|
const { path: targetPath = projectPath } = args;
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
617
|
+
try {
|
|
618
|
+
const analyzer = new CoverageAnalyzer(db, targetPath);
|
|
619
|
+
const result = await analyzer.analyze();
|
|
620
|
+
// Format output for AI agents
|
|
621
|
+
let output = `Analysis Coverage for ${targetPath}\n`;
|
|
622
|
+
output += `==============================\n\n`;
|
|
623
|
+
output += `File breakdown:\n`;
|
|
624
|
+
output += ` Total files: ${result.total}\n`;
|
|
625
|
+
output += ` Analyzed: ${result.analyzed.count} (${result.percentages.analyzed}%) - in graph\n`;
|
|
626
|
+
output += ` Unsupported: ${result.unsupported.count} (${result.percentages.unsupported}%) - no indexer available\n`;
|
|
627
|
+
output += ` Unreachable: ${result.unreachable.count} (${result.percentages.unreachable}%) - not imported from entrypoints\n`;
|
|
628
|
+
if (result.unsupported.count > 0) {
|
|
629
|
+
output += `\nUnsupported files by extension:\n`;
|
|
630
|
+
for (const [ext, files] of Object.entries(result.unsupported.byExtension)) {
|
|
631
|
+
output += ` ${ext}: ${files.length} files\n`;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
if (result.unreachable.count > 0) {
|
|
635
|
+
output += `\nUnreachable source files:\n`;
|
|
636
|
+
for (const [ext, files] of Object.entries(result.unreachable.byExtension)) {
|
|
637
|
+
output += ` ${ext}: ${files.length} files\n`;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return textResult(output);
|
|
641
|
+
}
|
|
642
|
+
catch (error) {
|
|
643
|
+
return errorResult(`Failed to calculate coverage: ${error.message}`);
|
|
644
|
+
}
|
|
616
645
|
}
|
|
617
646
|
export async function handleGetDocumentation(args) {
|
|
618
647
|
const { topic = 'overview' } = args;
|
package/dist/state.d.ts
CHANGED
|
@@ -15,6 +15,45 @@ export declare function setIsAnalyzed(value: boolean): void;
|
|
|
15
15
|
export declare function setAnalysisStatus(status: Partial<AnalysisStatus>): void;
|
|
16
16
|
export declare function setBackgroundPid(pid: number | null): void;
|
|
17
17
|
export declare function updateAnalysisTimings(timings: Partial<AnalysisStatus['timings']>): void;
|
|
18
|
+
/**
|
|
19
|
+
* Check if analysis is currently running.
|
|
20
|
+
*
|
|
21
|
+
* Use this to check status before attempting operations that conflict
|
|
22
|
+
* with analysis (e.g., force re-analysis while analysis is in progress).
|
|
23
|
+
*
|
|
24
|
+
* @returns true if analysis is in progress, false otherwise
|
|
25
|
+
*/
|
|
26
|
+
export declare function isAnalysisRunning(): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Acquire the analysis lock.
|
|
29
|
+
*
|
|
30
|
+
* This function implements a Promise-based mutex for serializing analysis operations.
|
|
31
|
+
* Only one analysis can run at a time. If another analysis is running, this function
|
|
32
|
+
* waits for it to complete (up to LOCK_TIMEOUT_MS).
|
|
33
|
+
*
|
|
34
|
+
* Usage:
|
|
35
|
+
* ```typescript
|
|
36
|
+
* const releaseLock = await acquireAnalysisLock();
|
|
37
|
+
* try {
|
|
38
|
+
* // ... perform analysis ...
|
|
39
|
+
* } finally {
|
|
40
|
+
* releaseLock();
|
|
41
|
+
* }
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @returns A release function to call when analysis is complete
|
|
45
|
+
* @throws Error if timeout (10 minutes) expires while waiting for existing analysis
|
|
46
|
+
*/
|
|
47
|
+
export declare function acquireAnalysisLock(): Promise<() => void>;
|
|
48
|
+
/**
|
|
49
|
+
* Wait for any running analysis to complete without acquiring the lock.
|
|
50
|
+
*
|
|
51
|
+
* Use this when you need to wait for analysis completion but don't need
|
|
52
|
+
* to start a new analysis yourself.
|
|
53
|
+
*
|
|
54
|
+
* @returns Promise that resolves when no analysis is running
|
|
55
|
+
*/
|
|
56
|
+
export declare function waitForAnalysis(): Promise<void>;
|
|
18
57
|
export declare function getOrCreateBackend(): Promise<GraphBackend>;
|
|
19
58
|
export declare function getBackendIfExists(): GraphBackend | null;
|
|
20
59
|
export declare function setupLogging(): void;
|
package/dist/state.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAqB,gBAAgB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAIlF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../src/state.ts"],"names":[],"mappings":"AAAA;;GAEG;AAIH,OAAO,EAAqB,gBAAgB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAIlF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC;AA+EnD,wBAAgB,cAAc,IAAI,MAAM,CAEvC;AAED,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED,wBAAgB,iBAAiB,IAAI,cAAc,CAElD;AAED,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAEhD;AAED,wBAAgB,mBAAmB,IAAI,gBAAgB,GAAG,IAAI,CAE7D;AAED,wBAAgB,eAAe,IAAI,YAAY,GAAG,IAAI,CAErD;AAGD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAEjD;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAElD;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,OAAO,CAAC,cAAc,CAAC,GAAG,IAAI,CAEvE;AAED,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAEzD;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,CAAC,cAAc,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAEvF;AAID;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,MAAM,IAAI,CAAC,CA8B/D;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAIrD;AAGD,wBAAsB,kBAAkB,IAAI,OAAO,CAAC,YAAY,CAAC,CAgChE;AAkBD,wBAAgB,kBAAkB,IAAI,YAAY,GAAG,IAAI,CAExD;AAGD,wBAAgB,YAAY,IAAI,IAAI,CAMnC;AAGD,wBAAgB,kBAAkB,IAAI,IAAI,CAQzC;AAGD,wBAAsB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAI7C"}
|
package/dist/state.js
CHANGED
|
@@ -32,6 +32,51 @@ let analysisStatus = {
|
|
|
32
32
|
total: null,
|
|
33
33
|
},
|
|
34
34
|
};
|
|
35
|
+
// === ANALYSIS LOCK ===
|
|
36
|
+
//
|
|
37
|
+
// Promise-based mutex for analysis serialization.
|
|
38
|
+
//
|
|
39
|
+
// Why not a simple boolean flag?
|
|
40
|
+
// - Boolean can indicate "analysis is running" but cannot make callers wait
|
|
41
|
+
// - Promise allows awaiting until analysis completes
|
|
42
|
+
//
|
|
43
|
+
// Pattern:
|
|
44
|
+
// - null = no analysis running, lock available
|
|
45
|
+
// - Promise = analysis running, await it to wait for completion
|
|
46
|
+
//
|
|
47
|
+
// Behavior on force=true during analysis:
|
|
48
|
+
// - Returns error immediately (does NOT wait)
|
|
49
|
+
// - Rationale: force=true implies "clear DB and re-analyze"
|
|
50
|
+
// - Clearing DB while another analysis writes = corruption
|
|
51
|
+
// - Better UX: immediate feedback vs mysterious wait
|
|
52
|
+
//
|
|
53
|
+
// Scope: Global Lock (not per-service) because:
|
|
54
|
+
// - Single RFDB backend instance
|
|
55
|
+
// - db.clear() affects entire database
|
|
56
|
+
// - Simpler reasoning about state
|
|
57
|
+
//
|
|
58
|
+
// Process Death Behavior:
|
|
59
|
+
// - Lock is in-memory - next process starts with fresh state (no deadlock)
|
|
60
|
+
// - RFDB may have partial data from incomplete analysis
|
|
61
|
+
// - isAnalyzed resets to false - next call will re-analyze
|
|
62
|
+
// - RFDB is append-only - partial data won't corrupt existing data
|
|
63
|
+
//
|
|
64
|
+
// Worker Process Coordination:
|
|
65
|
+
// - Worker is SEPARATE process from MCP server
|
|
66
|
+
// - MCP server calls db.clear() INSIDE the lock, BEFORE spawning worker
|
|
67
|
+
// - Worker assumes DB is already clean and does NOT call clear()
|
|
68
|
+
//
|
|
69
|
+
// Timeout:
|
|
70
|
+
// - Lock acquisition times out after 10 minutes
|
|
71
|
+
// - Matches project's execution guard policy (see CLAUDE.md)
|
|
72
|
+
//
|
|
73
|
+
let analysisLock = null;
|
|
74
|
+
let analysisLockResolve = null;
|
|
75
|
+
/**
|
|
76
|
+
* Lock timeout in milliseconds (10 minutes).
|
|
77
|
+
* Matches project's execution guard policy - max 10 minutes for any operation.
|
|
78
|
+
*/
|
|
79
|
+
const LOCK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
35
80
|
// === GETTERS ===
|
|
36
81
|
export function getProjectPath() {
|
|
37
82
|
return projectPath;
|
|
@@ -67,6 +112,76 @@ export function setBackgroundPid(pid) {
|
|
|
67
112
|
export function updateAnalysisTimings(timings) {
|
|
68
113
|
analysisStatus.timings = { ...analysisStatus.timings, ...timings };
|
|
69
114
|
}
|
|
115
|
+
// === ANALYSIS LOCK FUNCTIONS ===
|
|
116
|
+
/**
|
|
117
|
+
* Check if analysis is currently running.
|
|
118
|
+
*
|
|
119
|
+
* Use this to check status before attempting operations that conflict
|
|
120
|
+
* with analysis (e.g., force re-analysis while analysis is in progress).
|
|
121
|
+
*
|
|
122
|
+
* @returns true if analysis is in progress, false otherwise
|
|
123
|
+
*/
|
|
124
|
+
export function isAnalysisRunning() {
|
|
125
|
+
return analysisLock !== null;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Acquire the analysis lock.
|
|
129
|
+
*
|
|
130
|
+
* This function implements a Promise-based mutex for serializing analysis operations.
|
|
131
|
+
* Only one analysis can run at a time. If another analysis is running, this function
|
|
132
|
+
* waits for it to complete (up to LOCK_TIMEOUT_MS).
|
|
133
|
+
*
|
|
134
|
+
* Usage:
|
|
135
|
+
* ```typescript
|
|
136
|
+
* const releaseLock = await acquireAnalysisLock();
|
|
137
|
+
* try {
|
|
138
|
+
* // ... perform analysis ...
|
|
139
|
+
* } finally {
|
|
140
|
+
* releaseLock();
|
|
141
|
+
* }
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* @returns A release function to call when analysis is complete
|
|
145
|
+
* @throws Error if timeout (10 minutes) expires while waiting for existing analysis
|
|
146
|
+
*/
|
|
147
|
+
export async function acquireAnalysisLock() {
|
|
148
|
+
const start = Date.now();
|
|
149
|
+
// Wait for any existing analysis to complete (with timeout)
|
|
150
|
+
while (analysisLock !== null) {
|
|
151
|
+
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
|
152
|
+
throw new Error('Analysis lock timeout (10 minutes). Previous analysis may have failed. ' +
|
|
153
|
+
'Check .grafema/mcp.log for errors or restart MCP server.');
|
|
154
|
+
}
|
|
155
|
+
await analysisLock;
|
|
156
|
+
}
|
|
157
|
+
// Create new lock - a Promise that will be resolved when analysis completes
|
|
158
|
+
analysisLock = new Promise((resolve) => {
|
|
159
|
+
analysisLockResolve = resolve;
|
|
160
|
+
});
|
|
161
|
+
// Update status to reflect that analysis is running
|
|
162
|
+
setAnalysisStatus({ running: true });
|
|
163
|
+
// Return release function
|
|
164
|
+
return () => {
|
|
165
|
+
setAnalysisStatus({ running: false });
|
|
166
|
+
const resolve = analysisLockResolve;
|
|
167
|
+
analysisLock = null;
|
|
168
|
+
analysisLockResolve = null;
|
|
169
|
+
resolve?.();
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Wait for any running analysis to complete without acquiring the lock.
|
|
174
|
+
*
|
|
175
|
+
* Use this when you need to wait for analysis completion but don't need
|
|
176
|
+
* to start a new analysis yourself.
|
|
177
|
+
*
|
|
178
|
+
* @returns Promise that resolves when no analysis is running
|
|
179
|
+
*/
|
|
180
|
+
export async function waitForAnalysis() {
|
|
181
|
+
if (analysisLock) {
|
|
182
|
+
await analysisLock;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
70
185
|
// === BACKEND ===
|
|
71
186
|
export async function getOrCreateBackend() {
|
|
72
187
|
if (backend)
|
package/dist/types.d.ts
CHANGED
|
@@ -28,13 +28,8 @@ export interface PaginationParams {
|
|
|
28
28
|
total?: number;
|
|
29
29
|
hasMore: boolean;
|
|
30
30
|
}
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
backend?: 'local' | 'rfdb';
|
|
34
|
-
rfdb_socket?: string;
|
|
35
|
-
plugins?: string[];
|
|
36
|
-
ignore_patterns?: string[];
|
|
37
|
-
}
|
|
31
|
+
export type { GrafemaConfig } from '@grafema/core';
|
|
32
|
+
export type { MCPConfig } from './config.js';
|
|
38
33
|
export interface QueryGraphArgs {
|
|
39
34
|
query: string;
|
|
40
35
|
limit?: number;
|
|
@@ -48,7 +43,7 @@ export interface FindCallsArgs {
|
|
|
48
43
|
include_indirect?: boolean;
|
|
49
44
|
}
|
|
50
45
|
export interface TraceAliasArgs {
|
|
51
|
-
|
|
46
|
+
variableName: string;
|
|
52
47
|
file?: string;
|
|
53
48
|
max_depth?: number;
|
|
54
49
|
}
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,IAAI,CAAC;AAGtC,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,OAAO,EAAE,eAAe,CAAC;CAC1B;AAGD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,OAAO,CAAC;CAClB;AAGD,
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grafema/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1-alpha",
|
|
4
4
|
"description": "MCP server for Grafema code analysis toolkit",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/server.js",
|
|
@@ -38,16 +38,19 @@
|
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
40
40
|
"ajv": "^8.17.1",
|
|
41
|
-
"@grafema/
|
|
42
|
-
"@grafema/
|
|
41
|
+
"@grafema/types": "0.1.1-alpha",
|
|
42
|
+
"@grafema/core": "0.1.1-alpha"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^25.0.8",
|
|
46
|
+
"tsx": "^4.19.2",
|
|
46
47
|
"typescript": "^5.9.3"
|
|
47
48
|
},
|
|
48
49
|
"scripts": {
|
|
49
50
|
"build": "tsc",
|
|
50
51
|
"clean": "rm -rf dist",
|
|
51
|
-
"start": "node dist/server.js"
|
|
52
|
+
"start": "node dist/server.js",
|
|
53
|
+
"test": "node --import tsx --test test/*.test.ts",
|
|
54
|
+
"test:watch": "node --import tsx --test --watch test/*.test.ts"
|
|
52
55
|
}
|
|
53
56
|
}
|
package/src/analysis-worker.ts
CHANGED
|
@@ -213,7 +213,12 @@ async function run(): Promise<void> {
|
|
|
213
213
|
console.log(`[Worker] Connecting to RFDB server: socket=${socketPath}, db=${dbPath}`);
|
|
214
214
|
db = new RFDBServerBackend({ socketPath, dbPath });
|
|
215
215
|
await db.connect();
|
|
216
|
-
|
|
216
|
+
|
|
217
|
+
// NOTE: db.clear() is NOT called here.
|
|
218
|
+
// MCP server clears DB INSIDE the analysis lock BEFORE spawning this worker.
|
|
219
|
+
// This prevents race conditions where concurrent analysis calls could both
|
|
220
|
+
// clear the database. Worker assumes DB is already clean.
|
|
221
|
+
// See: REG-159 implementation, Phase 2.5 (Worker Clear Coordination)
|
|
217
222
|
|
|
218
223
|
sendProgress({ phase: 'discovery', message: 'Starting analysis...' });
|
|
219
224
|
|
package/src/analysis.ts
CHANGED
|
@@ -10,20 +10,67 @@ import {
|
|
|
10
10
|
setIsAnalyzed,
|
|
11
11
|
getAnalysisStatus,
|
|
12
12
|
setAnalysisStatus,
|
|
13
|
+
isAnalysisRunning,
|
|
14
|
+
acquireAnalysisLock,
|
|
13
15
|
} from './state.js';
|
|
14
|
-
import { loadConfig, loadCustomPlugins,
|
|
16
|
+
import { loadConfig, loadCustomPlugins, createPlugins } from './config.js';
|
|
15
17
|
import { log } from './utils.js';
|
|
16
18
|
import type { GraphBackend } from '@grafema/types';
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
|
-
* Ensure project is analyzed, optionally filtering to a single service
|
|
21
|
+
* Ensure project is analyzed, optionally filtering to a single service.
|
|
22
|
+
*
|
|
23
|
+
* CONCURRENCY: This function is protected by a global mutex.
|
|
24
|
+
* - Only one analysis can run at a time
|
|
25
|
+
* - Concurrent calls wait for the current analysis to complete
|
|
26
|
+
* - force=true while analysis is running returns an error immediately
|
|
27
|
+
*
|
|
28
|
+
* @param serviceName - Optional service to analyze (null = all)
|
|
29
|
+
* @param force - If true, clear DB and re-analyze even if already analyzed.
|
|
30
|
+
* ERROR if another analysis is already running.
|
|
31
|
+
* @throws Error if force=true and analysis is already running
|
|
20
32
|
*/
|
|
21
|
-
export async function ensureAnalyzed(
|
|
33
|
+
export async function ensureAnalyzed(
|
|
34
|
+
serviceName: string | null = null,
|
|
35
|
+
force: boolean = false
|
|
36
|
+
): Promise<GraphBackend> {
|
|
22
37
|
const db = await getOrCreateBackend();
|
|
23
38
|
const projectPath = getProjectPath();
|
|
24
|
-
const isAnalyzed = getIsAnalyzed();
|
|
25
39
|
|
|
26
|
-
|
|
40
|
+
// CONCURRENCY CHECK: If force=true and analysis is running, error immediately
|
|
41
|
+
// This check is BEFORE acquiring lock to fail fast
|
|
42
|
+
if (force && isAnalysisRunning()) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'Analysis is already in progress. Cannot force re-analysis while another analysis is running. ' +
|
|
45
|
+
'Wait for the current analysis to complete or check status with get_analysis_status.'
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Skip if already analyzed (and not forcing, and no service filter)
|
|
50
|
+
if (getIsAnalyzed() && !serviceName && !force) {
|
|
51
|
+
return db;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Acquire lock (waits if another analysis is running)
|
|
55
|
+
const releaseLock = await acquireAnalysisLock();
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// Double-check after acquiring lock (another call might have completed analysis while we waited)
|
|
59
|
+
if (getIsAnalyzed() && !serviceName && !force) {
|
|
60
|
+
return db;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Clear DB inside lock, BEFORE running analysis
|
|
64
|
+
// This is critical for worker coordination: MCP server clears DB here,
|
|
65
|
+
// worker does NOT call db.clear() (see analysis-worker.ts)
|
|
66
|
+
if (force || !getIsAnalyzed()) {
|
|
67
|
+
log('[Grafema MCP] Clearing database before analysis...');
|
|
68
|
+
if (db.clear) {
|
|
69
|
+
await db.clear();
|
|
70
|
+
}
|
|
71
|
+
setIsAnalyzed(false);
|
|
72
|
+
}
|
|
73
|
+
|
|
27
74
|
log(
|
|
28
75
|
`[Grafema MCP] Analyzing project: ${projectPath}${serviceName ? ` (service: ${serviceName})` : ''}`
|
|
29
76
|
);
|
|
@@ -31,30 +78,8 @@ export async function ensureAnalyzed(serviceName: string | null = null): Promise
|
|
|
31
78
|
const config = loadConfig(projectPath);
|
|
32
79
|
const { pluginMap: customPluginMap } = await loadCustomPlugins(projectPath);
|
|
33
80
|
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
...BUILTIN_PLUGINS,
|
|
37
|
-
...Object.fromEntries(
|
|
38
|
-
Object.entries(customPluginMap).map(([name, PluginClass]) => [
|
|
39
|
-
name,
|
|
40
|
-
() => new PluginClass(),
|
|
41
|
-
])
|
|
42
|
-
),
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
// Build plugin list from config
|
|
46
|
-
const plugins: unknown[] = [];
|
|
47
|
-
for (const [phase, pluginNames] of Object.entries(config.plugins || {})) {
|
|
48
|
-
for (const name of pluginNames as string[]) {
|
|
49
|
-
const factory = availablePlugins[name];
|
|
50
|
-
if (factory) {
|
|
51
|
-
plugins.push(factory());
|
|
52
|
-
log(`[Grafema MCP] Enabled plugin: ${name} (${phase})`);
|
|
53
|
-
} else {
|
|
54
|
-
log(`[Grafema MCP] Warning: Unknown plugin ${name} in config`);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
81
|
+
// Create plugins from config
|
|
82
|
+
const plugins = createPlugins(config.plugins, customPluginMap);
|
|
58
83
|
|
|
59
84
|
log(`[Grafema MCP] Total plugins: ${plugins.length}`);
|
|
60
85
|
|
|
@@ -105,10 +130,13 @@ export async function ensureAnalyzed(serviceName: string | null = null): Promise
|
|
|
105
130
|
},
|
|
106
131
|
});
|
|
107
132
|
|
|
108
|
-
log(`[Grafema MCP]
|
|
109
|
-
}
|
|
133
|
+
log(`[Grafema MCP] Analysis complete in ${totalTime}s`);
|
|
110
134
|
|
|
111
|
-
|
|
135
|
+
return db;
|
|
136
|
+
} finally {
|
|
137
|
+
// ALWAYS release the lock, even on error
|
|
138
|
+
releaseLock();
|
|
139
|
+
}
|
|
112
140
|
}
|
|
113
141
|
|
|
114
142
|
/**
|
|
@@ -133,7 +161,7 @@ export async function discoverServices(): Promise<unknown[]> {
|
|
|
133
161
|
};
|
|
134
162
|
|
|
135
163
|
const plugins: unknown[] = [];
|
|
136
|
-
const discoveryPluginNames =
|
|
164
|
+
const discoveryPluginNames = config.plugins.discovery ?? [];
|
|
137
165
|
|
|
138
166
|
for (const name of discoveryPluginNames) {
|
|
139
167
|
const factory = availablePlugins[name];
|
package/src/config.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { join } from 'path';
|
|
6
|
-
import { existsSync,
|
|
6
|
+
import { existsSync, readdirSync } from 'fs';
|
|
7
7
|
import { pathToFileURL } from 'url';
|
|
8
8
|
import { log } from './utils.js';
|
|
9
|
-
import type
|
|
9
|
+
import { loadConfig as loadConfigFromCore, type GrafemaConfig } from '@grafema/core';
|
|
10
10
|
|
|
11
11
|
// === PLUGIN IMPORTS ===
|
|
12
12
|
import {
|
|
@@ -41,18 +41,12 @@ import {
|
|
|
41
41
|
TypeScriptDeadCodeValidator,
|
|
42
42
|
} from '@grafema/core';
|
|
43
43
|
|
|
44
|
-
// ===
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
discovery?: string[];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface ProjectConfig {
|
|
54
|
-
plugins: PluginConfig;
|
|
55
|
-
discovery: {
|
|
44
|
+
// === MCP-SPECIFIC CONFIG ===
|
|
45
|
+
/**
|
|
46
|
+
* MCP-specific configuration extends GrafemaConfig with additional fields.
|
|
47
|
+
*/
|
|
48
|
+
export interface MCPConfig extends GrafemaConfig {
|
|
49
|
+
discovery?: {
|
|
56
50
|
enabled: boolean;
|
|
57
51
|
customOnly: boolean;
|
|
58
52
|
};
|
|
@@ -63,35 +57,7 @@ export interface ProjectConfig {
|
|
|
63
57
|
rfdb_socket?: string;
|
|
64
58
|
}
|
|
65
59
|
|
|
66
|
-
|
|
67
|
-
plugins: {
|
|
68
|
-
indexing: ['JSModuleIndexer'],
|
|
69
|
-
analysis: [
|
|
70
|
-
'JSASTAnalyzer',
|
|
71
|
-
'ExpressRouteAnalyzer',
|
|
72
|
-
'SocketIOAnalyzer',
|
|
73
|
-
'DatabaseAnalyzer',
|
|
74
|
-
'FetchAnalyzer',
|
|
75
|
-
'ServiceLayerAnalyzer',
|
|
76
|
-
],
|
|
77
|
-
enrichment: [
|
|
78
|
-
'MethodCallResolver',
|
|
79
|
-
'AliasTracker',
|
|
80
|
-
'ValueDomainAnalyzer',
|
|
81
|
-
'MountPointResolver',
|
|
82
|
-
'PrefixEvaluator',
|
|
83
|
-
'HTTPConnectionEnricher',
|
|
84
|
-
],
|
|
85
|
-
validation: [
|
|
86
|
-
'CallResolverValidator',
|
|
87
|
-
'EvalBanValidator',
|
|
88
|
-
'SQLInjectionValidator',
|
|
89
|
-
'ShadowingDetector',
|
|
90
|
-
'GraphConnectivityValidator',
|
|
91
|
-
'DataFlowValidator',
|
|
92
|
-
'TypeScriptDeadCodeValidator',
|
|
93
|
-
],
|
|
94
|
-
},
|
|
60
|
+
const MCP_DEFAULTS: Pick<MCPConfig, 'discovery'> = {
|
|
95
61
|
discovery: {
|
|
96
62
|
enabled: true,
|
|
97
63
|
customOnly: false,
|
|
@@ -137,33 +103,21 @@ export const BUILTIN_PLUGINS: Record<string, PluginFactory> = {
|
|
|
137
103
|
};
|
|
138
104
|
|
|
139
105
|
// === CONFIG LOADING ===
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return DEFAULT_CONFIG;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
try {
|
|
159
|
-
const configContent = readFileSync(configPath, 'utf-8');
|
|
160
|
-
const config = JSON.parse(configContent) as Partial<ProjectConfig>;
|
|
161
|
-
log(`[Grafema MCP] Loaded config from ${configPath}`);
|
|
162
|
-
return { ...DEFAULT_CONFIG, ...config };
|
|
163
|
-
} catch (err) {
|
|
164
|
-
log(`[Grafema MCP] Failed to load config: ${(err as Error).message}, using defaults`);
|
|
165
|
-
return DEFAULT_CONFIG;
|
|
166
|
-
}
|
|
106
|
+
/**
|
|
107
|
+
* Load MCP configuration (extends base GrafemaConfig).
|
|
108
|
+
* Uses shared ConfigLoader but adds MCP-specific defaults.
|
|
109
|
+
*/
|
|
110
|
+
export function loadConfig(projectPath: string): MCPConfig {
|
|
111
|
+
// Use shared loader (handles YAML/JSON, deprecation warnings)
|
|
112
|
+
const baseConfig = loadConfigFromCore(projectPath, {
|
|
113
|
+
warn: (msg) => log(`[Grafema MCP] ${msg}`),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Add MCP-specific defaults
|
|
117
|
+
return {
|
|
118
|
+
...baseConfig,
|
|
119
|
+
...MCP_DEFAULTS,
|
|
120
|
+
};
|
|
167
121
|
}
|
|
168
122
|
|
|
169
123
|
// === CUSTOM PLUGINS ===
|
|
@@ -212,9 +166,16 @@ export async function loadCustomPlugins(projectPath: string): Promise<CustomPlug
|
|
|
212
166
|
|
|
213
167
|
// === PLUGIN INSTANTIATION ===
|
|
214
168
|
export function createPlugins(
|
|
215
|
-
|
|
169
|
+
config: GrafemaConfig['plugins'],
|
|
216
170
|
customPluginMap: Record<string, new () => unknown> = {}
|
|
217
171
|
): unknown[] {
|
|
172
|
+
const pluginNames = [
|
|
173
|
+
...config.indexing,
|
|
174
|
+
...config.analysis,
|
|
175
|
+
...config.enrichment,
|
|
176
|
+
...config.validation,
|
|
177
|
+
];
|
|
178
|
+
|
|
218
179
|
const plugins: unknown[] = [];
|
|
219
180
|
const availablePlugins: Record<string, PluginFactory> = {
|
|
220
181
|
...BUILTIN_PLUGINS,
|
package/src/handlers.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { join } from 'path';
|
|
6
6
|
import { ensureAnalyzed } from './analysis.js';
|
|
7
|
-
import { getProjectPath, getAnalysisStatus,
|
|
7
|
+
import { getProjectPath, getAnalysisStatus, getOrCreateBackend, getGuaranteeManager, getGuaranteeAPI, isAnalysisRunning } from './state.js';
|
|
8
|
+
import { CoverageAnalyzer } from '@grafema/core';
|
|
8
9
|
import {
|
|
9
10
|
normalizeLimit,
|
|
10
11
|
formatPaginationInfo,
|
|
@@ -248,7 +249,7 @@ export async function handleFindNodes(args: FindNodesArgs): Promise<ToolResult>
|
|
|
248
249
|
|
|
249
250
|
export async function handleTraceAlias(args: TraceAliasArgs): Promise<ToolResult> {
|
|
250
251
|
const db = await ensureAnalyzed();
|
|
251
|
-
const {
|
|
252
|
+
const { variableName, file } = args;
|
|
252
253
|
const projectPath = getProjectPath();
|
|
253
254
|
|
|
254
255
|
let varNode: GraphNode | null = null;
|
|
@@ -423,16 +424,24 @@ export async function handleCheckInvariant(args: CheckInvariantArgs): Promise<To
|
|
|
423
424
|
export async function handleAnalyzeProject(args: AnalyzeProjectArgs): Promise<ToolResult> {
|
|
424
425
|
const { service, force } = args;
|
|
425
426
|
|
|
426
|
-
|
|
427
|
-
|
|
427
|
+
// Early check: return error for force=true if analysis is already running
|
|
428
|
+
// This provides immediate feedback instead of waiting or causing corruption
|
|
429
|
+
if (force && isAnalysisRunning()) {
|
|
430
|
+
return errorResult(
|
|
431
|
+
'Cannot force re-analysis: analysis is already in progress. ' +
|
|
432
|
+
'Use get_analysis_status to check current status, or wait for completion.'
|
|
433
|
+
);
|
|
428
434
|
}
|
|
429
435
|
|
|
436
|
+
// Note: setIsAnalyzed(false) is now handled inside ensureAnalyzed() within the lock
|
|
437
|
+
// to prevent race conditions where multiple calls could both clear the database
|
|
438
|
+
|
|
430
439
|
try {
|
|
431
|
-
await ensureAnalyzed(service || null);
|
|
440
|
+
await ensureAnalyzed(service || null, force || false);
|
|
432
441
|
const status = getAnalysisStatus();
|
|
433
442
|
|
|
434
443
|
return textResult(
|
|
435
|
-
|
|
444
|
+
`Analysis complete!\n` +
|
|
436
445
|
`- Services discovered: ${status.servicesDiscovered}\n` +
|
|
437
446
|
`- Services analyzed: ${status.servicesAnalyzed}\n` +
|
|
438
447
|
`- Total time: ${status.timings.total || 'N/A'}s`
|
|
@@ -764,14 +773,38 @@ export async function handleGetCoverage(args: GetCoverageArgs): Promise<ToolResu
|
|
|
764
773
|
const projectPath = getProjectPath();
|
|
765
774
|
const { path: targetPath = projectPath } = args;
|
|
766
775
|
|
|
767
|
-
|
|
768
|
-
|
|
776
|
+
try {
|
|
777
|
+
const analyzer = new CoverageAnalyzer(db, targetPath);
|
|
778
|
+
const result = await analyzer.analyze();
|
|
779
|
+
|
|
780
|
+
// Format output for AI agents
|
|
781
|
+
let output = `Analysis Coverage for ${targetPath}\n`;
|
|
782
|
+
output += `==============================\n\n`;
|
|
783
|
+
|
|
784
|
+
output += `File breakdown:\n`;
|
|
785
|
+
output += ` Total files: ${result.total}\n`;
|
|
786
|
+
output += ` Analyzed: ${result.analyzed.count} (${result.percentages.analyzed}%) - in graph\n`;
|
|
787
|
+
output += ` Unsupported: ${result.unsupported.count} (${result.percentages.unsupported}%) - no indexer available\n`;
|
|
788
|
+
output += ` Unreachable: ${result.unreachable.count} (${result.percentages.unreachable}%) - not imported from entrypoints\n`;
|
|
789
|
+
|
|
790
|
+
if (result.unsupported.count > 0) {
|
|
791
|
+
output += `\nUnsupported files by extension:\n`;
|
|
792
|
+
for (const [ext, files] of Object.entries(result.unsupported.byExtension)) {
|
|
793
|
+
output += ` ${ext}: ${files.length} files\n`;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
769
796
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
797
|
+
if (result.unreachable.count > 0) {
|
|
798
|
+
output += `\nUnreachable source files:\n`;
|
|
799
|
+
for (const [ext, files] of Object.entries(result.unreachable.byExtension)) {
|
|
800
|
+
output += ` ${ext}: ${files.length} files\n`;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
return textResult(output);
|
|
805
|
+
} catch (error) {
|
|
806
|
+
return errorResult(`Failed to calculate coverage: ${(error as Error).message}`);
|
|
807
|
+
}
|
|
775
808
|
}
|
|
776
809
|
|
|
777
810
|
export async function handleGetDocumentation(args: GetDocumentationArgs): Promise<ToolResult> {
|
package/src/state.ts
CHANGED
|
@@ -40,6 +40,53 @@ let analysisStatus: AnalysisStatus = {
|
|
|
40
40
|
},
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
// === ANALYSIS LOCK ===
|
|
44
|
+
//
|
|
45
|
+
// Promise-based mutex for analysis serialization.
|
|
46
|
+
//
|
|
47
|
+
// Why not a simple boolean flag?
|
|
48
|
+
// - Boolean can indicate "analysis is running" but cannot make callers wait
|
|
49
|
+
// - Promise allows awaiting until analysis completes
|
|
50
|
+
//
|
|
51
|
+
// Pattern:
|
|
52
|
+
// - null = no analysis running, lock available
|
|
53
|
+
// - Promise = analysis running, await it to wait for completion
|
|
54
|
+
//
|
|
55
|
+
// Behavior on force=true during analysis:
|
|
56
|
+
// - Returns error immediately (does NOT wait)
|
|
57
|
+
// - Rationale: force=true implies "clear DB and re-analyze"
|
|
58
|
+
// - Clearing DB while another analysis writes = corruption
|
|
59
|
+
// - Better UX: immediate feedback vs mysterious wait
|
|
60
|
+
//
|
|
61
|
+
// Scope: Global Lock (not per-service) because:
|
|
62
|
+
// - Single RFDB backend instance
|
|
63
|
+
// - db.clear() affects entire database
|
|
64
|
+
// - Simpler reasoning about state
|
|
65
|
+
//
|
|
66
|
+
// Process Death Behavior:
|
|
67
|
+
// - Lock is in-memory - next process starts with fresh state (no deadlock)
|
|
68
|
+
// - RFDB may have partial data from incomplete analysis
|
|
69
|
+
// - isAnalyzed resets to false - next call will re-analyze
|
|
70
|
+
// - RFDB is append-only - partial data won't corrupt existing data
|
|
71
|
+
//
|
|
72
|
+
// Worker Process Coordination:
|
|
73
|
+
// - Worker is SEPARATE process from MCP server
|
|
74
|
+
// - MCP server calls db.clear() INSIDE the lock, BEFORE spawning worker
|
|
75
|
+
// - Worker assumes DB is already clean and does NOT call clear()
|
|
76
|
+
//
|
|
77
|
+
// Timeout:
|
|
78
|
+
// - Lock acquisition times out after 10 minutes
|
|
79
|
+
// - Matches project's execution guard policy (see CLAUDE.md)
|
|
80
|
+
//
|
|
81
|
+
let analysisLock: Promise<void> | null = null;
|
|
82
|
+
let analysisLockResolve: (() => void) | null = null;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Lock timeout in milliseconds (10 minutes).
|
|
86
|
+
* Matches project's execution guard policy - max 10 minutes for any operation.
|
|
87
|
+
*/
|
|
88
|
+
const LOCK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
89
|
+
|
|
43
90
|
// === GETTERS ===
|
|
44
91
|
export function getProjectPath(): string {
|
|
45
92
|
return projectPath;
|
|
@@ -86,6 +133,86 @@ export function updateAnalysisTimings(timings: Partial<AnalysisStatus['timings']
|
|
|
86
133
|
analysisStatus.timings = { ...analysisStatus.timings, ...timings };
|
|
87
134
|
}
|
|
88
135
|
|
|
136
|
+
// === ANALYSIS LOCK FUNCTIONS ===
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Check if analysis is currently running.
|
|
140
|
+
*
|
|
141
|
+
* Use this to check status before attempting operations that conflict
|
|
142
|
+
* with analysis (e.g., force re-analysis while analysis is in progress).
|
|
143
|
+
*
|
|
144
|
+
* @returns true if analysis is in progress, false otherwise
|
|
145
|
+
*/
|
|
146
|
+
export function isAnalysisRunning(): boolean {
|
|
147
|
+
return analysisLock !== null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Acquire the analysis lock.
|
|
152
|
+
*
|
|
153
|
+
* This function implements a Promise-based mutex for serializing analysis operations.
|
|
154
|
+
* Only one analysis can run at a time. If another analysis is running, this function
|
|
155
|
+
* waits for it to complete (up to LOCK_TIMEOUT_MS).
|
|
156
|
+
*
|
|
157
|
+
* Usage:
|
|
158
|
+
* ```typescript
|
|
159
|
+
* const releaseLock = await acquireAnalysisLock();
|
|
160
|
+
* try {
|
|
161
|
+
* // ... perform analysis ...
|
|
162
|
+
* } finally {
|
|
163
|
+
* releaseLock();
|
|
164
|
+
* }
|
|
165
|
+
* ```
|
|
166
|
+
*
|
|
167
|
+
* @returns A release function to call when analysis is complete
|
|
168
|
+
* @throws Error if timeout (10 minutes) expires while waiting for existing analysis
|
|
169
|
+
*/
|
|
170
|
+
export async function acquireAnalysisLock(): Promise<() => void> {
|
|
171
|
+
const start = Date.now();
|
|
172
|
+
|
|
173
|
+
// Wait for any existing analysis to complete (with timeout)
|
|
174
|
+
while (analysisLock !== null) {
|
|
175
|
+
if (Date.now() - start > LOCK_TIMEOUT_MS) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
'Analysis lock timeout (10 minutes). Previous analysis may have failed. ' +
|
|
178
|
+
'Check .grafema/mcp.log for errors or restart MCP server.'
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
await analysisLock;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Create new lock - a Promise that will be resolved when analysis completes
|
|
185
|
+
analysisLock = new Promise<void>((resolve) => {
|
|
186
|
+
analysisLockResolve = resolve;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Update status to reflect that analysis is running
|
|
190
|
+
setAnalysisStatus({ running: true });
|
|
191
|
+
|
|
192
|
+
// Return release function
|
|
193
|
+
return () => {
|
|
194
|
+
setAnalysisStatus({ running: false });
|
|
195
|
+
const resolve = analysisLockResolve;
|
|
196
|
+
analysisLock = null;
|
|
197
|
+
analysisLockResolve = null;
|
|
198
|
+
resolve?.();
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Wait for any running analysis to complete without acquiring the lock.
|
|
204
|
+
*
|
|
205
|
+
* Use this when you need to wait for analysis completion but don't need
|
|
206
|
+
* to start a new analysis yourself.
|
|
207
|
+
*
|
|
208
|
+
* @returns Promise that resolves when no analysis is running
|
|
209
|
+
*/
|
|
210
|
+
export async function waitForAnalysis(): Promise<void> {
|
|
211
|
+
if (analysisLock) {
|
|
212
|
+
await analysisLock;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
89
216
|
// === BACKEND ===
|
|
90
217
|
export async function getOrCreateBackend(): Promise<GraphBackend> {
|
|
91
218
|
if (backend) return backend;
|
package/src/types.ts
CHANGED
|
@@ -36,13 +36,8 @@ export interface PaginationParams {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// === CONFIG ===
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
backend?: 'local' | 'rfdb';
|
|
42
|
-
rfdb_socket?: string;
|
|
43
|
-
plugins?: string[];
|
|
44
|
-
ignore_patterns?: string[];
|
|
45
|
-
}
|
|
39
|
+
export type { GrafemaConfig } from '@grafema/core';
|
|
40
|
+
export type { MCPConfig } from './config.js';
|
|
46
41
|
|
|
47
42
|
// === TOOL ARGUMENTS ===
|
|
48
43
|
export interface QueryGraphArgs {
|
|
@@ -60,7 +55,7 @@ export interface FindCallsArgs {
|
|
|
60
55
|
}
|
|
61
56
|
|
|
62
57
|
export interface TraceAliasArgs {
|
|
63
|
-
|
|
58
|
+
variableName: string;
|
|
64
59
|
file?: string;
|
|
65
60
|
max_depth?: number;
|
|
66
61
|
}
|