Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 6 additions & 26 deletions .detective/config.json
Original file line number Diff line number Diff line change
@@ -1,40 +1,20 @@
{
"scopes": [
"apps/frontend",
"apps/backend/src/infrastructure",
"apps/backend/src/mcp",
"apps/backend/src/model",
"apps/backend/src/options",
"apps/backend/src/services",
"apps/backend/src/utils",
"apps/backend/src/infrastructure",
"apps/frontend/src/app/features/coupling",
"apps/frontend/src/app/features/hotspot",
"apps/frontend/src/app/features/team-alignment",
"apps/frontend/src/app/shell/about",
"apps/frontend/src/app/shell/filter-tree",
"apps/frontend/src/app/shell/nav",
"apps/frontend/src/app/model",
"apps/frontend/src/app/ui/doughnut",
"apps/frontend/src/app/ui/graph",
"apps/frontend/src/app/ui/limits",
"apps/frontend/src/app/ui/loading",
"apps/frontend/src/app/ui/resizer",
"apps/frontend/src/app/ui/treemap"
],
"groups": [
"apps/backend/src",
"apps/backend",
"apps/frontend/src/app/features",
"apps/frontend/src/app/shell",
"apps/frontend/src/app/ui",
"apps/frontend/src/app",
"apps/frontend/src",
"apps/frontend",
"apps"
"apps/backend/src/utils"
],
"groups": ["apps/backend/src", "apps/backend", "apps"],
"entries": [],
"filter": {
"files": [],
"logs": []
},
"aliases": {},
"teams": {
"example-team-a": ["John Doe", "Jane Doe"],
"example-team-b": ["Max Muster", "Susi Sorglos"]
Expand Down
2 changes: 1 addition & 1 deletion .detective/hash
Original file line number Diff line number Diff line change
@@ -1 +1 @@
503c63bcf7fab325fc9687a40ad048b2b16ca636, v1.1.6
0a7d0219e81ae606e07a3ab3fedd5499f9db7f49, v1.3.0
5,789 changes: 0 additions & 5,789 deletions .detective/log

This file was deleted.

15 changes: 15 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:4300",
"webRoot": "${workspaceFolder}"
}
]
}
8 changes: 7 additions & 1 deletion apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
"@softarc/detective": "./bin/main.js"
},
"dependencies": {
"tslib": "^2.0.0"
"@jscpd/core": "^4.0.1",
"@jscpd/tokenizer": "^4.0.1",
"@modelcontextprotocol/sdk": "1.17.3",
"reflect-metadata": "^0.2.2",
"tslib": "^2.0.0",
"zod": "3.25.76",
"zod-to-json-schema": "^3.23.5"
},
"author": "Manfred Steyer",
"license": "MIT",
Expand Down
8 changes: 2 additions & 6 deletions apps/backend/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,8 @@
"options": {
"buildTarget": "backend:build",
"runBuildTargetDependencies": false,
"args": [
"--path",
"/Users/manfredsteyer/projects/public/standalone-example-cli",
"--open",
"false"
]
"inspect": true,
"args": ["--path", "."]
},
"configurations": {
"development": {
Expand Down
274 changes: 274 additions & 0 deletions apps/backend/src/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { cwd } from 'process';
import express from 'express';

import { getCommitCount } from './infrastructure/git';
import { createMcpHttpRouter } from './mcp/http-router';
import { createMcpServer } from './mcp/server';
import { Limits } from './model/limits';
import { Options } from './options/options';
import { calcChangeCoupling } from './services/change-coupling';
Expand All @@ -19,12 +21,40 @@ import {
import { isStale, updateLogCache } from './services/log-cache';
import { calcModuleInfo } from './services/module-info';
import { calcTeamAlignment } from './services/team-alignment';
import {
runTrendAnalysis,
formatTrendAnalysisForAPI,
GitService,
} from './services/trend-analysis';

// Global trend analysis status
const trendAnalysisStatus: {
isRunning: boolean;
lastRun: Date | null;
lastResult: unknown;
} = {
isRunning: false,
lastRun: null as Date | null,
lastResult: null as unknown,
};

export function updateTrendAnalysisStatus(
update: Partial<typeof trendAnalysisStatus>
) {
Object.assign(trendAnalysisStatus, update);
}

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

app.use(express.json());

// MCP integration (Streamable HTTP) enabled by default at /mcp
app.use(
'/mcp',
createMcpHttpRouter(() => createMcpServer(options))
);

app.get('/api/config', (req, res) => {
res.sendFile(path.join(cwd(), options.config));
});
Expand Down Expand Up @@ -157,6 +187,250 @@ export function setupExpress(options: Options) {
}
});

app.get('/api/trend-analysis/status', (req, res) => {
res.json({
isRunning: trendAnalysisStatus.isRunning,
lastRun: trendAnalysisStatus.lastRun?.toISOString(),
hasResults: !!trendAnalysisStatus.lastResult,
});
});

app.get('/api/trend-analysis', async (req, res) => {
const maxCommits = Number(req.query.maxCommits) || 50;
const parallelWorkers = Math.max(
1,
Math.min(10, Number(req.query.parallelWorkers) || 5)
); // Limit between 1-10 workers
const fileExtensions = req.query.fileExtensions
? String(req.query.fileExtensions).split(',')
: ['.ts', '.js', '.tsx', '.jsx'];

// Check if we have cached results and no new analysis is requested
if (trendAnalysisStatus.lastResult && !req.query.fresh) {
res.json(trendAnalysisStatus.lastResult);
return;
}

// Prevent multiple concurrent analyses
if (trendAnalysisStatus.isRunning) {
res.status(429).json({
error:
'Trend analysis is already running. Check /api/trend-analysis/status for progress.',
});
return;
}

try {
trendAnalysisStatus.isRunning = true;

const result = await runTrendAnalysis(options, {
maxCommits,
fileExtensions,
parallelWorkers,
});

const formattedResult = await formatTrendAnalysisForAPI(
result,
options.path,
fileExtensions
);

// Cache the results
trendAnalysisStatus.lastResult = formattedResult;
trendAnalysisStatus.lastRun = new Date();

res.json(formattedResult);
} catch (e: unknown) {
handleError(e, res);
} finally {
trendAnalysisStatus.isRunning = false;
}
});

// Streaming trend analysis endpoint with Server-Sent Events
app.get('/api/trend-analysis/stream', async (req, res) => {
const maxCommits = Number(req.query.maxCommits) || 50;
const parallelWorkers = Math.max(
1,
Math.min(10, Number(req.query.parallelWorkers) || 5)
); // Limit between 1-10 workers
const fileExtensions = req.query.fileExtensions
? String(req.query.fileExtensions).split(',')
: ['.ts', '.js', '.tsx', '.jsx'];

// Prevent multiple concurrent analyses
if (trendAnalysisStatus.isRunning) {
res.status(429).json({
error:
'Trend analysis is already running. Check /api/trend-analysis/status for progress.',
});
return;
}

// Set up SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Cache-Control',
});

// Send initial connection message
res.write(
`data: ${JSON.stringify({
type: 'connected',
message: 'Connected to trend analysis stream',
})}\n\n`
);

try {
trendAnalysisStatus.isRunning = true;

// FIRST: Send the complete file structure immediately
const gitService = new GitService(options.path);
const currentFiles = await gitService.getCurrentFiles(fileExtensions);

const initialFileStructure = currentFiles.map((filePath) => ({
filePath,
changeFrequency: 0,
averageComplexity: 0,
averageSize: 0,
totalChanges: 0,
commits: [],
complexityTrend: [],
sizeTrend: [],
}));

// Send initial file structure
res.write(
`data: ${JSON.stringify({
type: 'initial_files',
message: `Loaded ${currentFiles.length} files from current commit`,
data: {
files: initialFileStructure,
summary: {
totalProcessingTimeMs: 0,
commitsAnalyzed: 0,
filesAnalyzed: currentFiles.length,
commitHashes: [],
},
},
})}\n\n`
);

// THEN: Start the trend analysis with streaming updates
const result = await runTrendAnalysis(options, {
maxCommits,
fileExtensions,
parallelWorkers,
progressCallback: (update) => {
// Send progress update via SSE
res.write(`data: ${JSON.stringify(update)}\n\n`);
},
});

const formattedResult = await formatTrendAnalysisForAPI(
result,
options.path,
fileExtensions
);

// Cache the results
trendAnalysisStatus.lastResult = formattedResult;
trendAnalysisStatus.lastRun = new Date();

// Send final result
res.write(
`data: ${JSON.stringify({
type: 'final_result',
message: 'Analysis complete',
data: formattedResult,
})}\n\n`
);
} catch (error: unknown) {
const message =
typeof error === 'object' && error && 'message' in error
? error.message
: '' + error;
res.write(
`data: ${JSON.stringify({
type: 'error',
message: `Analysis failed: ${message}`,
progress: 100,
})}\n\n`
);
} finally {
trendAnalysisStatus.isRunning = false;
res.write(
`event: close\ndata: ${JSON.stringify({
type: 'stream_end',
message: 'Stream ended',
})}\n\n`
);
res.end();
}
});

// X-Ray code analysis endpoint
app.get('/api/x-ray', async (req, res) => {
const filePath = req.query.file as string;
const includeSource = req.query.includeSource === 'true';

if (!filePath) {
res.status(400).json({ error: 'file query parameter is required' });
return;
}

try {
// Resolve the file path relative to the project root
const fullPath = path.resolve(options.path, filePath);

// Check if file exists
if (!fs.existsSync(fullPath)) {
res.status(404).json({ error: `File not found: ${filePath}` });
return;
}

// Create analyzer and run analysis (lazy import to prevent startup scanning)
const { CodeAnalyzer } = await import(
'./services/trend-analysis/x-ray/code-analyzer'
);
const analyzer = new CodeAnalyzer(fullPath);
const metrics = await analyzer.analyze(includeSource);

res.json({ ...metrics, schemaUrl: '/api/x-ray/schema?v=1' });
} catch (e: unknown) {
handleError(e, res);
}
});

// X-Ray schema endpoint
app.get('/api/x-ray/schema', async (_req, res) => {
try {
const { CodeAnalyzer } = await import(
'./services/trend-analysis/x-ray/code-analyzer'
);
const { buildBaseXRaySchema } = await import(
'./services/trend-analysis/x-ray/x-ray.schema'
);

const jsonSchema = CodeAnalyzer.buildJSONSchema() as Record<
string,
unknown
>;
// Prefer UI schema embedded under jsonSchema['x-ui'] for a single-source payload
const uiSchema =
(jsonSchema as Record<string, unknown>)['x-ui'] ??
CodeAnalyzer.buildUISchema();
const base = buildBaseXRaySchema();

res.json({ version: base.version, jsonSchema, uiSchema });
} catch (e: unknown) {
handleError(e, res);
}
});

app.use(express.static(path.join(__dirname, 'assets')));

app.get('*', (req, res) => {
Expand Down
Loading