Skip to content

Commit 3597515

Browse files
Merge pull request #44 from wolfmanfx/main
feat: trend analysis / metrics support
2 parents 33dbb43 + 7665e45 commit 3597515

141 files changed

Lines changed: 19380 additions & 8152 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.detective/config.json

Lines changed: 6 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,20 @@
11
{
22
"scopes": [
3+
"apps/frontend",
4+
"apps/backend/src/infrastructure",
5+
"apps/backend/src/mcp",
36
"apps/backend/src/model",
47
"apps/backend/src/options",
58
"apps/backend/src/services",
6-
"apps/backend/src/utils",
7-
"apps/backend/src/infrastructure",
8-
"apps/frontend/src/app/features/coupling",
9-
"apps/frontend/src/app/features/hotspot",
10-
"apps/frontend/src/app/features/team-alignment",
11-
"apps/frontend/src/app/shell/about",
12-
"apps/frontend/src/app/shell/filter-tree",
13-
"apps/frontend/src/app/shell/nav",
14-
"apps/frontend/src/app/model",
15-
"apps/frontend/src/app/ui/doughnut",
16-
"apps/frontend/src/app/ui/graph",
17-
"apps/frontend/src/app/ui/limits",
18-
"apps/frontend/src/app/ui/loading",
19-
"apps/frontend/src/app/ui/resizer",
20-
"apps/frontend/src/app/ui/treemap"
21-
],
22-
"groups": [
23-
"apps/backend/src",
24-
"apps/backend",
25-
"apps/frontend/src/app/features",
26-
"apps/frontend/src/app/shell",
27-
"apps/frontend/src/app/ui",
28-
"apps/frontend/src/app",
29-
"apps/frontend/src",
30-
"apps/frontend",
31-
"apps"
9+
"apps/backend/src/utils"
3210
],
11+
"groups": ["apps/backend/src", "apps/backend", "apps"],
3312
"entries": [],
3413
"filter": {
3514
"files": [],
3615
"logs": []
3716
},
17+
"aliases": {},
3818
"teams": {
3919
"example-team-a": ["John Doe", "Jane Doe"],
4020
"example-team-b": ["Max Muster", "Susi Sorglos"]

.detective/hash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
503c63bcf7fab325fc9687a40ad048b2b16ca636, v1.1.6
1+
0a7d0219e81ae606e07a3ab3fedd5499f9db7f49, v1.3.0

.detective/log

Lines changed: 0 additions & 5789 deletions
This file was deleted.

.vscode/launch.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
// Use IntelliSense to learn about possible attributes.
3+
// Hover to view descriptions of existing attributes.
4+
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5+
"version": "0.2.0",
6+
"configurations": [
7+
{
8+
"type": "chrome",
9+
"request": "launch",
10+
"name": "Launch Chrome against localhost",
11+
"url": "http://localhost:4300",
12+
"webRoot": "${workspaceFolder}"
13+
}
14+
]
15+
}

apps/backend/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
"@softarc/detective": "./bin/main.js"
77
},
88
"dependencies": {
9-
"tslib": "^2.0.0"
9+
"@jscpd/core": "^4.0.1",
10+
"@jscpd/tokenizer": "^4.0.1",
11+
"@modelcontextprotocol/sdk": "1.17.3",
12+
"reflect-metadata": "^0.2.2",
13+
"tslib": "^2.0.0",
14+
"zod": "3.25.76",
15+
"zod-to-json-schema": "^3.23.5"
1016
},
1117
"author": "Manfred Steyer",
1218
"license": "MIT",

apps/backend/project.json

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,8 @@
1212
"options": {
1313
"buildTarget": "backend:build",
1414
"runBuildTargetDependencies": false,
15-
"args": [
16-
"--path",
17-
"/Users/manfredsteyer/projects/public/standalone-example-cli",
18-
"--open",
19-
"false"
20-
]
15+
"inspect": true,
16+
"args": ["--path", "."]
2117
},
2218
"configurations": {
2319
"development": {

apps/backend/src/express.ts

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { cwd } from 'process';
55
import express from 'express';
66

77
import { getCommitCount } from './infrastructure/git';
8+
import { createMcpHttpRouter } from './mcp/http-router';
9+
import { createMcpServer } from './mcp/server';
810
import { Limits } from './model/limits';
911
import { Options } from './options/options';
1012
import { calcChangeCoupling } from './services/change-coupling';
@@ -19,12 +21,40 @@ import {
1921
import { isStale, updateLogCache } from './services/log-cache';
2022
import { calcModuleInfo } from './services/module-info';
2123
import { calcTeamAlignment } from './services/team-alignment';
24+
import {
25+
runTrendAnalysis,
26+
formatTrendAnalysisForAPI,
27+
GitService,
28+
} from './services/trend-analysis';
29+
30+
// Global trend analysis status
31+
const trendAnalysisStatus: {
32+
isRunning: boolean;
33+
lastRun: Date | null;
34+
lastResult: unknown;
35+
} = {
36+
isRunning: false,
37+
lastRun: null as Date | null,
38+
lastResult: null as unknown,
39+
};
40+
41+
export function updateTrendAnalysisStatus(
42+
update: Partial<typeof trendAnalysisStatus>
43+
) {
44+
Object.assign(trendAnalysisStatus, update);
45+
}
2246

2347
export function setupExpress(options: Options) {
2448
const app = express();
2549

2650
app.use(express.json());
2751

52+
// MCP integration (Streamable HTTP) enabled by default at /mcp
53+
app.use(
54+
'/mcp',
55+
createMcpHttpRouter(() => createMcpServer(options))
56+
);
57+
2858
app.get('/api/config', (req, res) => {
2959
res.sendFile(path.join(cwd(), options.config));
3060
});
@@ -157,6 +187,250 @@ export function setupExpress(options: Options) {
157187
}
158188
});
159189

190+
app.get('/api/trend-analysis/status', (req, res) => {
191+
res.json({
192+
isRunning: trendAnalysisStatus.isRunning,
193+
lastRun: trendAnalysisStatus.lastRun?.toISOString(),
194+
hasResults: !!trendAnalysisStatus.lastResult,
195+
});
196+
});
197+
198+
app.get('/api/trend-analysis', async (req, res) => {
199+
const maxCommits = Number(req.query.maxCommits) || 50;
200+
const parallelWorkers = Math.max(
201+
1,
202+
Math.min(10, Number(req.query.parallelWorkers) || 5)
203+
); // Limit between 1-10 workers
204+
const fileExtensions = req.query.fileExtensions
205+
? String(req.query.fileExtensions).split(',')
206+
: ['.ts', '.js', '.tsx', '.jsx'];
207+
208+
// Check if we have cached results and no new analysis is requested
209+
if (trendAnalysisStatus.lastResult && !req.query.fresh) {
210+
res.json(trendAnalysisStatus.lastResult);
211+
return;
212+
}
213+
214+
// Prevent multiple concurrent analyses
215+
if (trendAnalysisStatus.isRunning) {
216+
res.status(429).json({
217+
error:
218+
'Trend analysis is already running. Check /api/trend-analysis/status for progress.',
219+
});
220+
return;
221+
}
222+
223+
try {
224+
trendAnalysisStatus.isRunning = true;
225+
226+
const result = await runTrendAnalysis(options, {
227+
maxCommits,
228+
fileExtensions,
229+
parallelWorkers,
230+
});
231+
232+
const formattedResult = await formatTrendAnalysisForAPI(
233+
result,
234+
options.path,
235+
fileExtensions
236+
);
237+
238+
// Cache the results
239+
trendAnalysisStatus.lastResult = formattedResult;
240+
trendAnalysisStatus.lastRun = new Date();
241+
242+
res.json(formattedResult);
243+
} catch (e: unknown) {
244+
handleError(e, res);
245+
} finally {
246+
trendAnalysisStatus.isRunning = false;
247+
}
248+
});
249+
250+
// Streaming trend analysis endpoint with Server-Sent Events
251+
app.get('/api/trend-analysis/stream', async (req, res) => {
252+
const maxCommits = Number(req.query.maxCommits) || 50;
253+
const parallelWorkers = Math.max(
254+
1,
255+
Math.min(10, Number(req.query.parallelWorkers) || 5)
256+
); // Limit between 1-10 workers
257+
const fileExtensions = req.query.fileExtensions
258+
? String(req.query.fileExtensions).split(',')
259+
: ['.ts', '.js', '.tsx', '.jsx'];
260+
261+
// Prevent multiple concurrent analyses
262+
if (trendAnalysisStatus.isRunning) {
263+
res.status(429).json({
264+
error:
265+
'Trend analysis is already running. Check /api/trend-analysis/status for progress.',
266+
});
267+
return;
268+
}
269+
270+
// Set up SSE headers
271+
res.writeHead(200, {
272+
'Content-Type': 'text/event-stream',
273+
'Cache-Control': 'no-cache',
274+
Connection: 'keep-alive',
275+
'Access-Control-Allow-Origin': '*',
276+
'Access-Control-Allow-Headers': 'Cache-Control',
277+
});
278+
279+
// Send initial connection message
280+
res.write(
281+
`data: ${JSON.stringify({
282+
type: 'connected',
283+
message: 'Connected to trend analysis stream',
284+
})}\n\n`
285+
);
286+
287+
try {
288+
trendAnalysisStatus.isRunning = true;
289+
290+
// FIRST: Send the complete file structure immediately
291+
const gitService = new GitService(options.path);
292+
const currentFiles = await gitService.getCurrentFiles(fileExtensions);
293+
294+
const initialFileStructure = currentFiles.map((filePath) => ({
295+
filePath,
296+
changeFrequency: 0,
297+
averageComplexity: 0,
298+
averageSize: 0,
299+
totalChanges: 0,
300+
commits: [],
301+
complexityTrend: [],
302+
sizeTrend: [],
303+
}));
304+
305+
// Send initial file structure
306+
res.write(
307+
`data: ${JSON.stringify({
308+
type: 'initial_files',
309+
message: `Loaded ${currentFiles.length} files from current commit`,
310+
data: {
311+
files: initialFileStructure,
312+
summary: {
313+
totalProcessingTimeMs: 0,
314+
commitsAnalyzed: 0,
315+
filesAnalyzed: currentFiles.length,
316+
commitHashes: [],
317+
},
318+
},
319+
})}\n\n`
320+
);
321+
322+
// THEN: Start the trend analysis with streaming updates
323+
const result = await runTrendAnalysis(options, {
324+
maxCommits,
325+
fileExtensions,
326+
parallelWorkers,
327+
progressCallback: (update) => {
328+
// Send progress update via SSE
329+
res.write(`data: ${JSON.stringify(update)}\n\n`);
330+
},
331+
});
332+
333+
const formattedResult = await formatTrendAnalysisForAPI(
334+
result,
335+
options.path,
336+
fileExtensions
337+
);
338+
339+
// Cache the results
340+
trendAnalysisStatus.lastResult = formattedResult;
341+
trendAnalysisStatus.lastRun = new Date();
342+
343+
// Send final result
344+
res.write(
345+
`data: ${JSON.stringify({
346+
type: 'final_result',
347+
message: 'Analysis complete',
348+
data: formattedResult,
349+
})}\n\n`
350+
);
351+
} catch (error: unknown) {
352+
const message =
353+
typeof error === 'object' && error && 'message' in error
354+
? error.message
355+
: '' + error;
356+
res.write(
357+
`data: ${JSON.stringify({
358+
type: 'error',
359+
message: `Analysis failed: ${message}`,
360+
progress: 100,
361+
})}\n\n`
362+
);
363+
} finally {
364+
trendAnalysisStatus.isRunning = false;
365+
res.write(
366+
`event: close\ndata: ${JSON.stringify({
367+
type: 'stream_end',
368+
message: 'Stream ended',
369+
})}\n\n`
370+
);
371+
res.end();
372+
}
373+
});
374+
375+
// X-Ray code analysis endpoint
376+
app.get('/api/x-ray', async (req, res) => {
377+
const filePath = req.query.file as string;
378+
const includeSource = req.query.includeSource === 'true';
379+
380+
if (!filePath) {
381+
res.status(400).json({ error: 'file query parameter is required' });
382+
return;
383+
}
384+
385+
try {
386+
// Resolve the file path relative to the project root
387+
const fullPath = path.resolve(options.path, filePath);
388+
389+
// Check if file exists
390+
if (!fs.existsSync(fullPath)) {
391+
res.status(404).json({ error: `File not found: ${filePath}` });
392+
return;
393+
}
394+
395+
// Create analyzer and run analysis (lazy import to prevent startup scanning)
396+
const { CodeAnalyzer } = await import(
397+
'./services/trend-analysis/x-ray/code-analyzer'
398+
);
399+
const analyzer = new CodeAnalyzer(fullPath);
400+
const metrics = await analyzer.analyze(includeSource);
401+
402+
res.json({ ...metrics, schemaUrl: '/api/x-ray/schema?v=1' });
403+
} catch (e: unknown) {
404+
handleError(e, res);
405+
}
406+
});
407+
408+
// X-Ray schema endpoint
409+
app.get('/api/x-ray/schema', async (_req, res) => {
410+
try {
411+
const { CodeAnalyzer } = await import(
412+
'./services/trend-analysis/x-ray/code-analyzer'
413+
);
414+
const { buildBaseXRaySchema } = await import(
415+
'./services/trend-analysis/x-ray/x-ray.schema'
416+
);
417+
418+
const jsonSchema = CodeAnalyzer.buildJSONSchema() as Record<
419+
string,
420+
unknown
421+
>;
422+
// Prefer UI schema embedded under jsonSchema['x-ui'] for a single-source payload
423+
const uiSchema =
424+
(jsonSchema as Record<string, unknown>)['x-ui'] ??
425+
CodeAnalyzer.buildUISchema();
426+
const base = buildBaseXRaySchema();
427+
428+
res.json({ version: base.version, jsonSchema, uiSchema });
429+
} catch (e: unknown) {
430+
handleError(e, res);
431+
}
432+
});
433+
160434
app.use(express.static(path.join(__dirname, 'assets')));
161435

162436
app.get('*', (req, res) => {

0 commit comments

Comments
 (0)