@@ -5,6 +5,8 @@ import { cwd } from 'process';
55import express from 'express' ;
66
77import { getCommitCount } from './infrastructure/git' ;
8+ import { createMcpHttpRouter } from './mcp/http-router' ;
9+ import { createMcpServer } from './mcp/server' ;
810import { Limits } from './model/limits' ;
911import { Options } from './options/options' ;
1012import { calcChangeCoupling } from './services/change-coupling' ;
@@ -19,12 +21,40 @@ import {
1921import { isStale , updateLogCache } from './services/log-cache' ;
2022import { calcModuleInfo } from './services/module-info' ;
2123import { 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
2347export 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