codeguardian-mcp 1.0.0
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/LICENSE +21 -0
- package/README.md +348 -0
- package/dist/agent/agentTools.d.ts +26 -0
- package/dist/agent/agentTools.d.ts.map +1 -0
- package/dist/agent/agentTools.js +699 -0
- package/dist/agent/agentTools.js.map +1 -0
- package/dist/agent/autoValidator.d.ts +110 -0
- package/dist/agent/autoValidator.d.ts.map +1 -0
- package/dist/agent/autoValidator.js +964 -0
- package/dist/agent/autoValidator.js.map +1 -0
- package/dist/agent/fileWatcher.d.ts +28 -0
- package/dist/agent/fileWatcher.d.ts.map +1 -0
- package/dist/agent/fileWatcher.js +88 -0
- package/dist/agent/fileWatcher.js.map +1 -0
- package/dist/agent/guardianPersistence.d.ts +98 -0
- package/dist/agent/guardianPersistence.d.ts.map +1 -0
- package/dist/agent/guardianPersistence.js +296 -0
- package/dist/agent/guardianPersistence.js.map +1 -0
- package/dist/agent/mcpNotifications.d.ts +38 -0
- package/dist/agent/mcpNotifications.d.ts.map +1 -0
- package/dist/agent/mcpNotifications.js +81 -0
- package/dist/agent/mcpNotifications.js.map +1 -0
- package/dist/analyzers/aiPatterns.d.ts +16 -0
- package/dist/analyzers/aiPatterns.d.ts.map +1 -0
- package/dist/analyzers/aiPatterns.js +103 -0
- package/dist/analyzers/aiPatterns.js.map +1 -0
- package/dist/analyzers/antiPatterns.d.ts +60 -0
- package/dist/analyzers/antiPatterns.d.ts.map +1 -0
- package/dist/analyzers/antiPatterns.js +198 -0
- package/dist/analyzers/antiPatterns.js.map +1 -0
- package/dist/analyzers/builtinTypes.d.ts +18 -0
- package/dist/analyzers/builtinTypes.d.ts.map +1 -0
- package/dist/analyzers/builtinTypes.js +1275 -0
- package/dist/analyzers/builtinTypes.js.map +1 -0
- package/dist/analyzers/complexity.d.ts +14 -0
- package/dist/analyzers/complexity.d.ts.map +1 -0
- package/dist/analyzers/complexity.js +610 -0
- package/dist/analyzers/complexity.js.map +1 -0
- package/dist/analyzers/findingVerifier.d.ts +59 -0
- package/dist/analyzers/findingVerifier.d.ts.map +1 -0
- package/dist/analyzers/findingVerifier.js +1169 -0
- package/dist/analyzers/findingVerifier.js.map +1 -0
- package/dist/analyzers/impactAnalyzer.d.ts +53 -0
- package/dist/analyzers/impactAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/impactAnalyzer.js +152 -0
- package/dist/analyzers/impactAnalyzer.js.map +1 -0
- package/dist/analyzers/languageDetector.d.ts +48 -0
- package/dist/analyzers/languageDetector.d.ts.map +1 -0
- package/dist/analyzers/languageDetector.js +404 -0
- package/dist/analyzers/languageDetector.js.map +1 -0
- package/dist/analyzers/parsers/incrementalParser.d.ts +53 -0
- package/dist/analyzers/parsers/incrementalParser.d.ts.map +1 -0
- package/dist/analyzers/parsers/incrementalParser.js +193 -0
- package/dist/analyzers/parsers/incrementalParser.js.map +1 -0
- package/dist/analyzers/parsers/scopeResolver.d.ts +92 -0
- package/dist/analyzers/parsers/scopeResolver.d.ts.map +1 -0
- package/dist/analyzers/parsers/scopeResolver.js +324 -0
- package/dist/analyzers/parsers/scopeResolver.js.map +1 -0
- package/dist/analyzers/parsers/semanticIndex.d.ts +127 -0
- package/dist/analyzers/parsers/semanticIndex.d.ts.map +1 -0
- package/dist/analyzers/parsers/semanticIndex.js +429 -0
- package/dist/analyzers/parsers/semanticIndex.js.map +1 -0
- package/dist/analyzers/parsers/sessionDiffAnalyzer.d.ts +42 -0
- package/dist/analyzers/parsers/sessionDiffAnalyzer.d.ts.map +1 -0
- package/dist/analyzers/parsers/sessionDiffAnalyzer.js +233 -0
- package/dist/analyzers/parsers/sessionDiffAnalyzer.js.map +1 -0
- package/dist/analyzers/parsers/treeSitterParser.d.ts +76 -0
- package/dist/analyzers/parsers/treeSitterParser.d.ts.map +1 -0
- package/dist/analyzers/parsers/treeSitterParser.js +709 -0
- package/dist/analyzers/parsers/treeSitterParser.js.map +1 -0
- package/dist/analyzers/relevanceScorer.d.ts +43 -0
- package/dist/analyzers/relevanceScorer.d.ts.map +1 -0
- package/dist/analyzers/relevanceScorer.js +200 -0
- package/dist/analyzers/relevanceScorer.js.map +1 -0
- package/dist/analyzers/standardLibrary.d.ts +22 -0
- package/dist/analyzers/standardLibrary.d.ts.map +1 -0
- package/dist/analyzers/standardLibrary.js +211 -0
- package/dist/analyzers/standardLibrary.js.map +1 -0
- package/dist/analyzers/symbolGraph.d.ts +30 -0
- package/dist/analyzers/symbolGraph.d.ts.map +1 -0
- package/dist/analyzers/symbolGraph.js +380 -0
- package/dist/analyzers/symbolGraph.js.map +1 -0
- package/dist/analyzers/symbolTable.d.ts +18 -0
- package/dist/analyzers/symbolTable.d.ts.map +1 -0
- package/dist/analyzers/symbolTable.js +176 -0
- package/dist/analyzers/symbolTable.js.map +1 -0
- package/dist/analyzers/typeChecker.d.ts +13 -0
- package/dist/analyzers/typeChecker.d.ts.map +1 -0
- package/dist/analyzers/typeChecker.js +580 -0
- package/dist/analyzers/typeChecker.js.map +1 -0
- package/dist/analyzers/usagePatterns.d.ts +42 -0
- package/dist/analyzers/usagePatterns.d.ts.map +1 -0
- package/dist/analyzers/usagePatterns.js +75 -0
- package/dist/analyzers/usagePatterns.js.map +1 -0
- package/dist/api-contract/context/backend.d.ts +19 -0
- package/dist/api-contract/context/backend.d.ts.map +1 -0
- package/dist/api-contract/context/backend.js +64 -0
- package/dist/api-contract/context/backend.js.map +1 -0
- package/dist/api-contract/context/contract.d.ts +34 -0
- package/dist/api-contract/context/contract.d.ts.map +1 -0
- package/dist/api-contract/context/contract.js +306 -0
- package/dist/api-contract/context/contract.js.map +1 -0
- package/dist/api-contract/context/frontend.d.ts +19 -0
- package/dist/api-contract/context/frontend.d.ts.map +1 -0
- package/dist/api-contract/context/frontend.js +64 -0
- package/dist/api-contract/context/frontend.js.map +1 -0
- package/dist/api-contract/detector.d.ts +28 -0
- package/dist/api-contract/detector.d.ts.map +1 -0
- package/dist/api-contract/detector.js +393 -0
- package/dist/api-contract/detector.js.map +1 -0
- package/dist/api-contract/extractors/python.d.ts +32 -0
- package/dist/api-contract/extractors/python.d.ts.map +1 -0
- package/dist/api-contract/extractors/python.js +521 -0
- package/dist/api-contract/extractors/python.js.map +1 -0
- package/dist/api-contract/extractors/pythonAstUtils.d.ts +44 -0
- package/dist/api-contract/extractors/pythonAstUtils.d.ts.map +1 -0
- package/dist/api-contract/extractors/pythonAstUtils.js +489 -0
- package/dist/api-contract/extractors/pythonAstUtils.js.map +1 -0
- package/dist/api-contract/extractors/tsAstUtils.d.ts +47 -0
- package/dist/api-contract/extractors/tsAstUtils.d.ts.map +1 -0
- package/dist/api-contract/extractors/tsAstUtils.js +173 -0
- package/dist/api-contract/extractors/tsAstUtils.js.map +1 -0
- package/dist/api-contract/extractors/typescript.d.ts +32 -0
- package/dist/api-contract/extractors/typescript.d.ts.map +1 -0
- package/dist/api-contract/extractors/typescript.js +666 -0
- package/dist/api-contract/extractors/typescript.js.map +1 -0
- package/dist/api-contract/index.d.ts +104 -0
- package/dist/api-contract/index.d.ts.map +1 -0
- package/dist/api-contract/index.js +232 -0
- package/dist/api-contract/index.js.map +1 -0
- package/dist/api-contract/types.d.ts +151 -0
- package/dist/api-contract/types.d.ts.map +1 -0
- package/dist/api-contract/types.js +19 -0
- package/dist/api-contract/types.js.map +1 -0
- package/dist/api-contract/validators/endpoint.d.ts +21 -0
- package/dist/api-contract/validators/endpoint.d.ts.map +1 -0
- package/dist/api-contract/validators/endpoint.js +224 -0
- package/dist/api-contract/validators/endpoint.js.map +1 -0
- package/dist/api-contract/validators/index.d.ts +40 -0
- package/dist/api-contract/validators/index.d.ts.map +1 -0
- package/dist/api-contract/validators/index.js +875 -0
- package/dist/api-contract/validators/index.js.map +1 -0
- package/dist/api-contract/validators/parameter.d.ts +17 -0
- package/dist/api-contract/validators/parameter.d.ts.map +1 -0
- package/dist/api-contract/validators/parameter.js +250 -0
- package/dist/api-contract/validators/parameter.js.map +1 -0
- package/dist/api-contract/validators/type.d.ts +38 -0
- package/dist/api-contract/validators/type.d.ts.map +1 -0
- package/dist/api-contract/validators/type.js +244 -0
- package/dist/api-contract/validators/type.js.map +1 -0
- package/dist/context/apiContract/complexTypeSupport.d.ts +83 -0
- package/dist/context/apiContract/complexTypeSupport.d.ts.map +1 -0
- package/dist/context/apiContract/complexTypeSupport.js +665 -0
- package/dist/context/apiContract/complexTypeSupport.js.map +1 -0
- package/dist/context/apiContract/graphqlSupport.d.ts +105 -0
- package/dist/context/apiContract/graphqlSupport.d.ts.map +1 -0
- package/dist/context/apiContract/graphqlSupport.js +671 -0
- package/dist/context/apiContract/graphqlSupport.js.map +1 -0
- package/dist/context/apiContract/index.d.ts +14 -0
- package/dist/context/apiContract/index.d.ts.map +1 -0
- package/dist/context/apiContract/index.js +17 -0
- package/dist/context/apiContract/index.js.map +1 -0
- package/dist/context/apiContract/webSocketSupport.d.ts +104 -0
- package/dist/context/apiContract/webSocketSupport.d.ts.map +1 -0
- package/dist/context/apiContract/webSocketSupport.js +465 -0
- package/dist/context/apiContract/webSocketSupport.js.map +1 -0
- package/dist/context/apiContractContext.d.ts +15 -0
- package/dist/context/apiContractContext.d.ts.map +1 -0
- package/dist/context/apiContractContext.js +979 -0
- package/dist/context/apiContractContext.js.map +1 -0
- package/dist/context/apiContractExtraction.d.ts +52 -0
- package/dist/context/apiContractExtraction.d.ts.map +1 -0
- package/dist/context/apiContractExtraction.js +438 -0
- package/dist/context/apiContractExtraction.js.map +1 -0
- package/dist/context/contextLineage.d.ts +79 -0
- package/dist/context/contextLineage.d.ts.map +1 -0
- package/dist/context/contextLineage.js +259 -0
- package/dist/context/contextLineage.js.map +1 -0
- package/dist/context/contextOrchestrator.d.ts +57 -0
- package/dist/context/contextOrchestrator.d.ts.map +1 -0
- package/dist/context/contextOrchestrator.js +162 -0
- package/dist/context/contextOrchestrator.js.map +1 -0
- package/dist/context/intentTracker.d.ts +73 -0
- package/dist/context/intentTracker.d.ts.map +1 -0
- package/dist/context/intentTracker.js +168 -0
- package/dist/context/intentTracker.js.map +1 -0
- package/dist/context/projectContext.d.ts +219 -0
- package/dist/context/projectContext.d.ts.map +1 -0
- package/dist/context/projectContext.js +1984 -0
- package/dist/context/projectContext.js.map +1 -0
- package/dist/prompts/index.d.ts +17 -0
- package/dist/prompts/index.d.ts.map +1 -0
- package/dist/prompts/index.js +260 -0
- package/dist/prompts/index.js.map +1 -0
- package/dist/prompts/library.d.ts +51 -0
- package/dist/prompts/library.d.ts.map +1 -0
- package/dist/prompts/library.js +65 -0
- package/dist/prompts/library.js.map +1 -0
- package/dist/prompts/templates.d.ts +44 -0
- package/dist/prompts/templates.d.ts.map +1 -0
- package/dist/prompts/templates.js +97 -0
- package/dist/prompts/templates.js.map +1 -0
- package/dist/queue/jobPersistence.d.ts +46 -0
- package/dist/queue/jobPersistence.d.ts.map +1 -0
- package/dist/queue/jobPersistence.js +158 -0
- package/dist/queue/jobPersistence.js.map +1 -0
- package/dist/queue/jobQueue.d.ts +116 -0
- package/dist/queue/jobQueue.d.ts.map +1 -0
- package/dist/queue/jobQueue.js +275 -0
- package/dist/queue/jobQueue.js.map +1 -0
- package/dist/queue/validationJob.d.ts +69 -0
- package/dist/queue/validationJob.d.ts.map +1 -0
- package/dist/queue/validationJob.js +435 -0
- package/dist/queue/validationJob.js.map +1 -0
- package/dist/resources/index.d.ts +15 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +328 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/validationReportStore.d.ts +170 -0
- package/dist/resources/validationReportStore.d.ts.map +1 -0
- package/dist/resources/validationReportStore.js +515 -0
- package/dist/resources/validationReportStore.js.map +1 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +102 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/asyncValidation.d.ts +19 -0
- package/dist/tools/asyncValidation.d.ts.map +1 -0
- package/dist/tools/asyncValidation.js +346 -0
- package/dist/tools/asyncValidation.js.map +1 -0
- package/dist/tools/buildContext.d.ts +17 -0
- package/dist/tools/buildContext.d.ts.map +1 -0
- package/dist/tools/buildContext.js +188 -0
- package/dist/tools/buildContext.js.map +1 -0
- package/dist/tools/getDependencyGraph.d.ts +16 -0
- package/dist/tools/getDependencyGraph.d.ts.map +1 -0
- package/dist/tools/getDependencyGraph.js +436 -0
- package/dist/tools/getDependencyGraph.js.map +1 -0
- package/dist/tools/incrementalValidation.d.ts +71 -0
- package/dist/tools/incrementalValidation.d.ts.map +1 -0
- package/dist/tools/incrementalValidation.js +203 -0
- package/dist/tools/incrementalValidation.js.map +1 -0
- package/dist/tools/index.d.ts +24 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +106 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/validateCode.d.ts +17 -0
- package/dist/tools/validateCode.d.ts.map +1 -0
- package/dist/tools/validateCode.js +368 -0
- package/dist/tools/validateCode.js.map +1 -0
- package/dist/tools/validateCodeLite.d.ts +2 -0
- package/dist/tools/validateCodeLite.d.ts.map +1 -0
- package/dist/tools/validateCodeLite.js +2 -0
- package/dist/tools/validateCodeLite.js.map +1 -0
- package/dist/tools/validation/builtins.d.ts +92 -0
- package/dist/tools/validation/builtins.d.ts.map +1 -0
- package/dist/tools/validation/builtins.js +2184 -0
- package/dist/tools/validation/builtins.js.map +1 -0
- package/dist/tools/validation/contextualNaming.d.ts +99 -0
- package/dist/tools/validation/contextualNaming.d.ts.map +1 -0
- package/dist/tools/validation/contextualNaming.js +959 -0
- package/dist/tools/validation/contextualNaming.js.map +1 -0
- package/dist/tools/validation/deadCode.d.ts +115 -0
- package/dist/tools/validation/deadCode.d.ts.map +1 -0
- package/dist/tools/validation/deadCode.js +861 -0
- package/dist/tools/validation/deadCode.js.map +1 -0
- package/dist/tools/validation/extractors/index.d.ts +131 -0
- package/dist/tools/validation/extractors/index.d.ts.map +1 -0
- package/dist/tools/validation/extractors/index.js +233 -0
- package/dist/tools/validation/extractors/index.js.map +1 -0
- package/dist/tools/validation/extractors/javascript.d.ts +73 -0
- package/dist/tools/validation/extractors/javascript.d.ts.map +1 -0
- package/dist/tools/validation/extractors/javascript.js +1841 -0
- package/dist/tools/validation/extractors/javascript.js.map +1 -0
- package/dist/tools/validation/extractors/python.d.ts +93 -0
- package/dist/tools/validation/extractors/python.d.ts.map +1 -0
- package/dist/tools/validation/extractors/python.js +799 -0
- package/dist/tools/validation/extractors/python.js.map +1 -0
- package/dist/tools/validation/manifest.d.ts +45 -0
- package/dist/tools/validation/manifest.d.ts.map +1 -0
- package/dist/tools/validation/manifest.js +719 -0
- package/dist/tools/validation/manifest.js.map +1 -0
- package/dist/tools/validation/parser.d.ts +58 -0
- package/dist/tools/validation/parser.d.ts.map +1 -0
- package/dist/tools/validation/parser.js +232 -0
- package/dist/tools/validation/parser.js.map +1 -0
- package/dist/tools/validation/registry.d.ts +15 -0
- package/dist/tools/validation/registry.d.ts.map +1 -0
- package/dist/tools/validation/registry.js +169 -0
- package/dist/tools/validation/registry.js.map +1 -0
- package/dist/tools/validation/scoring.d.ts +54 -0
- package/dist/tools/validation/scoring.d.ts.map +1 -0
- package/dist/tools/validation/scoring.js +242 -0
- package/dist/tools/validation/scoring.js.map +1 -0
- package/dist/tools/validation/types.d.ts +120 -0
- package/dist/tools/validation/types.d.ts.map +1 -0
- package/dist/tools/validation/types.js +11 -0
- package/dist/tools/validation/types.js.map +1 -0
- package/dist/tools/validation/unusedLocals.d.ts +36 -0
- package/dist/tools/validation/unusedLocals.d.ts.map +1 -0
- package/dist/tools/validation/unusedLocals.js +333 -0
- package/dist/tools/validation/unusedLocals.js.map +1 -0
- package/dist/tools/validation/validation.d.ts +98 -0
- package/dist/tools/validation/validation.d.ts.map +1 -0
- package/dist/tools/validation/validation.js +1837 -0
- package/dist/tools/validation/validation.js.map +1 -0
- package/dist/types/codeGraph.d.ts +163 -0
- package/dist/types/codeGraph.d.ts.map +1 -0
- package/dist/types/codeGraph.js +9 -0
- package/dist/types/codeGraph.js.map +1 -0
- package/dist/types/symbolGraph.d.ts +68 -0
- package/dist/types/symbolGraph.d.ts.map +1 -0
- package/dist/types/symbolGraph.js +10 -0
- package/dist/types/symbolGraph.js.map +1 -0
- package/dist/types/tools.d.ts +43 -0
- package/dist/types/tools.d.ts.map +1 -0
- package/dist/types/tools.js +7 -0
- package/dist/types/tools.js.map +1 -0
- package/dist/utils/fileFilter.d.ts +37 -0
- package/dist/utils/fileFilter.d.ts.map +1 -0
- package/dist/utils/fileFilter.js +91 -0
- package/dist/utils/fileFilter.js.map +1 -0
- package/dist/utils/gitUtils.d.ts +28 -0
- package/dist/utils/gitUtils.d.ts.map +1 -0
- package/dist/utils/gitUtils.js +81 -0
- package/dist/utils/gitUtils.js.map +1 -0
- package/dist/utils/logger.d.ts +15 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +38 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/serialization.d.ts +25 -0
- package/dist/utils/serialization.d.ts.map +1 -0
- package/dist/utils/serialization.js +53 -0
- package/dist/utils/serialization.js.map +1 -0
- package/package.json +90 -0
|
@@ -0,0 +1,1984 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Project Context
|
|
3
|
+
*
|
|
4
|
+
* A centralized context system that builds a comprehensive project map once
|
|
5
|
+
* and shares it across all CodeGuardian tools. This enables:
|
|
6
|
+
*
|
|
7
|
+
* 1. Faster subsequent tool calls (no re-indexing)
|
|
8
|
+
* 2. Cross-tool insights (e.g., dead code + test coverage)
|
|
9
|
+
* 3. Smarter validation (knows what symbols exist in project)
|
|
10
|
+
* 4. Better relevance scoring (understands project structure)
|
|
11
|
+
*
|
|
12
|
+
* @format
|
|
13
|
+
*/
|
|
14
|
+
import { glob } from "glob";
|
|
15
|
+
import * as fs from "fs/promises";
|
|
16
|
+
import * as fsSync from "fs";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { logger } from "../utils/logger.js";
|
|
19
|
+
import { filterExcludedFiles, getExcludePatternsForPath, } from "../utils/fileFilter.js";
|
|
20
|
+
import { extractSymbolsAST, extractImportsAST, extractImportsASTWithOptions, } from "../tools/validation/extractors/index.js";
|
|
21
|
+
import { getGitInfo, generateCacheKey, hasGitChanged, } from "../utils/gitUtils.js";
|
|
22
|
+
import { buildSymbolGraph } from "../analyzers/symbolGraph.js";
|
|
23
|
+
import { serialize, deserialize } from "../utils/serialization.js";
|
|
24
|
+
import { extractApiContractContext } from "./apiContractContext.js";
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Helper Functions for Lazy API Contract Loading
|
|
27
|
+
// ============================================================================
|
|
28
|
+
/**
|
|
29
|
+
* Detect if project has frontend code based on file patterns and symbols
|
|
30
|
+
*/
|
|
31
|
+
function detectFrontendPresence(context) {
|
|
32
|
+
const frontendPatterns = [
|
|
33
|
+
'/frontend/', '/client/', '/web/', '/app/src/',
|
|
34
|
+
'/components/', '/pages/', '/views/', '/hooks/'
|
|
35
|
+
];
|
|
36
|
+
// Check file paths
|
|
37
|
+
for (const filePath of context.files.keys()) {
|
|
38
|
+
const normalizedPath = filePath.toLowerCase();
|
|
39
|
+
if (frontendPatterns.some(pattern => normalizedPath.includes(pattern))) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
// Check for React/Vue/Angular imports
|
|
43
|
+
const fileInfo = context.files.get(filePath);
|
|
44
|
+
if (fileInfo?.imports.some(imp => imp.source.includes('react') ||
|
|
45
|
+
imp.source.includes('vue') ||
|
|
46
|
+
imp.source.includes('@angular'))) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Check for frontend-specific symbols
|
|
51
|
+
for (const [symbolName, symbolInfos] of context.symbolIndex) {
|
|
52
|
+
for (const info of symbolInfos) {
|
|
53
|
+
if (info.symbol.kind === 'component' || info.symbol.kind === 'hook') {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Detect if project has backend code based on file patterns and symbols
|
|
62
|
+
*/
|
|
63
|
+
function detectBackendPresence(context) {
|
|
64
|
+
const backendPatterns = [
|
|
65
|
+
'/backend/', '/server/', '/api/', '/routes/',
|
|
66
|
+
'/routers/', '/controllers/', '/models/',
|
|
67
|
+
'main.py', 'app.py', 'server.js', 'index.js'
|
|
68
|
+
];
|
|
69
|
+
// Check file paths
|
|
70
|
+
for (const filePath of context.files.keys()) {
|
|
71
|
+
const normalizedPath = filePath.toLowerCase();
|
|
72
|
+
if (backendPatterns.some(pattern => normalizedPath.includes(pattern))) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
// Check for backend framework imports
|
|
76
|
+
const fileInfo = context.files.get(filePath);
|
|
77
|
+
if (fileInfo?.imports.some(imp => imp.source.includes('express') ||
|
|
78
|
+
imp.source.includes('fastapi') ||
|
|
79
|
+
imp.source.includes('flask') ||
|
|
80
|
+
imp.source.includes('fastify') ||
|
|
81
|
+
imp.source.includes('django'))) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Check for backend-specific symbols (route handlers, etc.)
|
|
86
|
+
for (const [symbolName, symbolInfos] of context.symbolIndex) {
|
|
87
|
+
for (const info of symbolInfos) {
|
|
88
|
+
// Check if symbol is in a backend file
|
|
89
|
+
const fileInfo = context.files.get(info.file);
|
|
90
|
+
if (fileInfo) {
|
|
91
|
+
const normalizedPath = info.file.toLowerCase();
|
|
92
|
+
if (backendPatterns.some(pattern => normalizedPath.includes(pattern))) {
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Check if a file is relevant to API contract validation.
|
|
102
|
+
* When these files change, the API contract context should be refreshed.
|
|
103
|
+
*/
|
|
104
|
+
function isApiContractRelevantFile(filePath) {
|
|
105
|
+
const normalized = filePath.toLowerCase();
|
|
106
|
+
// Frontend service files (API calls)
|
|
107
|
+
if (normalized.includes('/services/') && (normalized.endsWith('.ts') || normalized.endsWith('.tsx') || normalized.endsWith('.js'))) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
// Backend route/API files
|
|
111
|
+
if ((normalized.includes('/api/') || normalized.includes('/routes/') || normalized.includes('/routers/')) && normalized.endsWith('.py')) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
// Backend schema/model files (Pydantic models)
|
|
115
|
+
if (normalized.includes('/schemas/') && normalized.endsWith('.py')) {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
// Frontend type definition files
|
|
119
|
+
if ((normalized.includes('/types/') || normalized.includes('/interfaces/')) && (normalized.endsWith('.ts') || normalized.endsWith('.tsx'))) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const contextCache = new Map();
|
|
125
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
126
|
+
const QUICK_CHECK_SAMPLE_SIZE = 20; // Number of files to sample for quick staleness check
|
|
127
|
+
const CACHE_DIR_NAME = ".codeguardian";
|
|
128
|
+
const CACHE_FILE_NAME = "context_cache.json";
|
|
129
|
+
// Track projects with an active guardian — skips TTL/staleness checks since
|
|
130
|
+
// the file watcher keeps context fresh via refreshFileContext
|
|
131
|
+
const guardianActiveProjects = new Set();
|
|
132
|
+
/**
|
|
133
|
+
* Mark a project as having an active guardian.
|
|
134
|
+
* While active, getProjectContext skips TTL/staleness checks and returns
|
|
135
|
+
* the cached context directly (file watcher keeps it fresh).
|
|
136
|
+
*/
|
|
137
|
+
export function markGuardianActive(projectPath) {
|
|
138
|
+
guardianActiveProjects.add(projectPath);
|
|
139
|
+
logger.info(`Guardian active for ${projectPath} — context cache will be kept fresh by file watcher`);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Mark a project's guardian as stopped.
|
|
143
|
+
* Resumes normal TTL/staleness checks for cache validity.
|
|
144
|
+
*/
|
|
145
|
+
export function markGuardianInactive(projectPath) {
|
|
146
|
+
guardianActiveProjects.delete(projectPath);
|
|
147
|
+
logger.info(`Guardian stopped for ${projectPath} — resuming normal cache TTL`);
|
|
148
|
+
}
|
|
149
|
+
// ============================================================================
|
|
150
|
+
// Main API
|
|
151
|
+
// ============================================================================
|
|
152
|
+
/**
|
|
153
|
+
* Get or build project context
|
|
154
|
+
* Automatically builds context if not cached, and validates freshness
|
|
155
|
+
* Uses smart invalidation based on file modification times
|
|
156
|
+
*/
|
|
157
|
+
export async function getProjectContext(projectPath, options = {}) {
|
|
158
|
+
const { language = "all", forceRebuild = false, includeTests = true, maxFiles = 1000, } = options;
|
|
159
|
+
// Get git info for branch-aware caching
|
|
160
|
+
const gitInfo = await getGitInfo(projectPath);
|
|
161
|
+
const cacheKey = generateCacheKey(projectPath, language, includeTests, gitInfo);
|
|
162
|
+
const cached = contextCache.get(cacheKey);
|
|
163
|
+
// If guardian is actively watching this project, trust the cache —
|
|
164
|
+
// the file watcher keeps it fresh via refreshFileContext.
|
|
165
|
+
// Check ALL cache keys for this project (different tools may use different language params)
|
|
166
|
+
if (!forceRebuild && guardianActiveProjects.has(projectPath)) {
|
|
167
|
+
if (cached) {
|
|
168
|
+
logger.debug(`Using guardian-managed context for ${projectPath} (exact key match)`);
|
|
169
|
+
return cached.context;
|
|
170
|
+
}
|
|
171
|
+
// Try to find any cached context for this project path (different language key)
|
|
172
|
+
for (const [key, entry] of contextCache.entries()) {
|
|
173
|
+
if (key.startsWith(projectPath + ":")) {
|
|
174
|
+
logger.debug(`Using guardian-managed context for ${projectPath} (cross-language key match)`);
|
|
175
|
+
return entry.context;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Check if cache exists and is potentially valid
|
|
180
|
+
if (!forceRebuild && cached) {
|
|
181
|
+
const age = Date.now() - cached.timestamp;
|
|
182
|
+
// Check if git state changed (branch switch or new commits)
|
|
183
|
+
const gitChanged = await hasGitChanged(projectPath, cached.gitInfo);
|
|
184
|
+
if (gitChanged) {
|
|
185
|
+
logger.info(`Git state changed for ${projectPath} (branch/commit), rebuilding context...`);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// If cache is within TTL, do a quick staleness check
|
|
189
|
+
if (age < CACHE_TTL_MS) {
|
|
190
|
+
const isStale = await isContextStale(cached, projectPath);
|
|
191
|
+
if (!isStale) {
|
|
192
|
+
logger.info(`Using cached context for ${projectPath} (validated fresh, age: ${age}ms)`);
|
|
193
|
+
return cached.context;
|
|
194
|
+
}
|
|
195
|
+
logger.info(`Cache is stale for ${projectPath}, rebuilding...`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Try to load from disk if memory cache missed
|
|
200
|
+
if (!forceRebuild) {
|
|
201
|
+
const diskContext = await loadContextFromDisk(projectPath, gitInfo);
|
|
202
|
+
if (diskContext) {
|
|
203
|
+
// Rehydrate into memory cache
|
|
204
|
+
contextCache.set(cacheKey, diskContext);
|
|
205
|
+
// Perform synchronization check to handle files edited while agent was offline
|
|
206
|
+
return reconcileContextWithDisk(diskContext, projectPath, { language, includeTests });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Build fresh context (auto-build if not exists)
|
|
210
|
+
const gitBranch = gitInfo ? `${gitInfo.branch}@${gitInfo.commitSHA}` : "no-git";
|
|
211
|
+
logger.info(`Building project context for ${projectPath} [${gitBranch}]${cached ? " (cache invalidated)" : " (first build)"}`);
|
|
212
|
+
const startTime = Date.now();
|
|
213
|
+
// Create .codeguardian directory early so users see immediate feedback
|
|
214
|
+
// This ensures the directory exists before the potentially long context build
|
|
215
|
+
try {
|
|
216
|
+
const cacheDir = path.join(projectPath, CACHE_DIR_NAME);
|
|
217
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
218
|
+
logger.debug(`Created ${CACHE_DIR_NAME} directory at ${cacheDir}`);
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
logger.warn(`Failed to create ${CACHE_DIR_NAME} directory: ${err}`);
|
|
222
|
+
}
|
|
223
|
+
const context = await buildProjectContext(projectPath, {
|
|
224
|
+
language,
|
|
225
|
+
includeTests,
|
|
226
|
+
maxFiles,
|
|
227
|
+
});
|
|
228
|
+
const buildTime = Date.now() - startTime;
|
|
229
|
+
logger.info(`Project context built in ${buildTime}ms - ${context.files.size} files indexed`);
|
|
230
|
+
// Performance warning for large projects
|
|
231
|
+
if (buildTime > 30000) {
|
|
232
|
+
logger.warn(`Context build took ${buildTime}ms - consider using 'scope' parameter to limit files`);
|
|
233
|
+
}
|
|
234
|
+
// Store git info in context
|
|
235
|
+
context.gitInfo = gitInfo;
|
|
236
|
+
// Build file hash map for smart invalidation
|
|
237
|
+
const fileHashes = new Map();
|
|
238
|
+
for (const [filePath, fileInfo] of context.files) {
|
|
239
|
+
if (fileInfo.lastModified) {
|
|
240
|
+
fileHashes.set(filePath, fileInfo.lastModified);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Cache it
|
|
244
|
+
contextCache.set(cacheKey, {
|
|
245
|
+
context,
|
|
246
|
+
timestamp: Date.now(),
|
|
247
|
+
fileHashes,
|
|
248
|
+
fileCount: context.files.size,
|
|
249
|
+
gitInfo,
|
|
250
|
+
});
|
|
251
|
+
logger.info(`Context built in ${Date.now() - startTime}ms (${context.files.size} files) [${gitBranch}]`);
|
|
252
|
+
// Save to disk for persistence
|
|
253
|
+
try {
|
|
254
|
+
await saveContextToDisk(projectPath, contextCache.get(cacheKey));
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
logger.warn(`Failed to save context to disk: ${err instanceof Error ? err.message : String(err)}`);
|
|
258
|
+
}
|
|
259
|
+
return context;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Incrementally refresh a single file in the project context
|
|
263
|
+
*/
|
|
264
|
+
export async function refreshFileContext(projectPath, filePath, options = {}) {
|
|
265
|
+
const { language = "all", includeTests = true } = options;
|
|
266
|
+
// Get git info
|
|
267
|
+
const gitInfo = await getGitInfo(projectPath);
|
|
268
|
+
const cacheKey = generateCacheKey(projectPath, language, includeTests, gitInfo);
|
|
269
|
+
// Try memory cache first (exact key match)
|
|
270
|
+
let cached = contextCache.get(cacheKey);
|
|
271
|
+
// If exact key missed but guardian is active, try cross-language key match
|
|
272
|
+
if (!cached && guardianActiveProjects.has(projectPath)) {
|
|
273
|
+
for (const [key, entry] of contextCache.entries()) {
|
|
274
|
+
if (key.startsWith(projectPath + ":")) {
|
|
275
|
+
cached = entry;
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// If not in memory, try disk
|
|
281
|
+
if (!cached) {
|
|
282
|
+
const diskContext = await loadContextFromDisk(projectPath, gitInfo);
|
|
283
|
+
if (diskContext) {
|
|
284
|
+
contextCache.set(cacheKey, diskContext);
|
|
285
|
+
cached = diskContext;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// If we still don't have a context, we have to build it full
|
|
289
|
+
if (!cached) {
|
|
290
|
+
return getProjectContext(projectPath, { language, includeTests });
|
|
291
|
+
}
|
|
292
|
+
// Update the file in the context
|
|
293
|
+
await updateFileInContext(cached.context, filePath, projectPath);
|
|
294
|
+
// If the changed file is relevant to API contracts (services, routes, schemas),
|
|
295
|
+
// rebuild the API contract context so all tools see fresh data
|
|
296
|
+
if (cached.context.apiContract && isApiContractRelevantFile(filePath)) {
|
|
297
|
+
try {
|
|
298
|
+
logger.debug(`API contract relevant file changed: ${filePath} — refreshing API contract context...`);
|
|
299
|
+
cached.context.apiContract = await extractApiContractContext(cached.context);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
logger.warn(`Failed to refresh API contract context: ${err instanceof Error ? err.message : String(err)}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
// Update file hashes in cached record
|
|
306
|
+
try {
|
|
307
|
+
const stats = await fs.stat(filePath);
|
|
308
|
+
cached.fileHashes.set(filePath, stats.mtimeMs);
|
|
309
|
+
cached.timestamp = Date.now(); // Update timestamp to extend TTL
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
// File might have been deleted
|
|
313
|
+
cached.fileHashes.delete(filePath);
|
|
314
|
+
}
|
|
315
|
+
// Debounce disk writes — during rapid vibecoding, dozens of refreshes fire
|
|
316
|
+
// within seconds. Writing to disk on every one causes I/O contention and
|
|
317
|
+
// concurrent writes to the same file. Instead, schedule a single write
|
|
318
|
+
// after a 2-second quiet period.
|
|
319
|
+
debouncedSaveContextToDisk(projectPath, cached);
|
|
320
|
+
return cached.context;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Synchronize a cached context with the current state of the filesystem.
|
|
324
|
+
* Detects files changed while the agent was offline and performs an incremental catch-up.
|
|
325
|
+
*/
|
|
326
|
+
async function reconcileContextWithDisk(cached, projectPath, options) {
|
|
327
|
+
const { language, includeTests } = options;
|
|
328
|
+
// We perform a full timestamp scan to ensure 100% accuracy as requested.
|
|
329
|
+
// This detects any edits made while the agent was offline.
|
|
330
|
+
logger.debug(`Reconciling context with disk for ${projectPath}...`);
|
|
331
|
+
const startTime = Date.now();
|
|
332
|
+
try {
|
|
333
|
+
// 1. Find all current files on disk
|
|
334
|
+
const currentFilesOnDisk = await findProjectFiles(projectPath, language, includeTests);
|
|
335
|
+
const currentFileSet = new Set(currentFilesOnDisk);
|
|
336
|
+
const toUpdate = [];
|
|
337
|
+
// 2. Scan for deleted or modified files
|
|
338
|
+
for (const [filePath, cachedMtime] of cached.fileHashes.entries()) {
|
|
339
|
+
if (!currentFileSet.has(filePath)) {
|
|
340
|
+
toUpdate.push(filePath); // Deleted
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
try {
|
|
344
|
+
const stats = await fs.stat(filePath);
|
|
345
|
+
if (stats.mtimeMs > cachedMtime) {
|
|
346
|
+
toUpdate.push(filePath); // Modified
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
toUpdate.push(filePath);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// 3. Scan for new files
|
|
355
|
+
for (const filePath of currentFilesOnDisk) {
|
|
356
|
+
if (!cached.fileHashes.has(filePath)) {
|
|
357
|
+
toUpdate.push(filePath); // New
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (toUpdate.length === 0) {
|
|
361
|
+
return cached.context;
|
|
362
|
+
}
|
|
363
|
+
logger.info(`Updating context: ${toUpdate.length} files changed while offline.`);
|
|
364
|
+
// 4. Update files incrementally (skipping graph rebuild during batch)
|
|
365
|
+
const BATCH_SIZE = 20;
|
|
366
|
+
for (let i = 0; i < toUpdate.length; i += BATCH_SIZE) {
|
|
367
|
+
const batch = toUpdate.slice(i, i + BATCH_SIZE);
|
|
368
|
+
await Promise.all(batch.map(file => updateFileInContext(cached.context, file, projectPath, true)));
|
|
369
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
370
|
+
}
|
|
371
|
+
// 5. Rebuild symbol graph ONCE at the end
|
|
372
|
+
cached.context.symbolGraph = await buildSymbolGraph(cached.context, {
|
|
373
|
+
includeCallRelationships: true,
|
|
374
|
+
includeCoOccurrence: true,
|
|
375
|
+
minCoOccurrenceCount: 2,
|
|
376
|
+
});
|
|
377
|
+
// 5b. Refresh API contract context if any changed files are API-relevant
|
|
378
|
+
if (cached.context.apiContract && toUpdate.some(f => isApiContractRelevantFile(f))) {
|
|
379
|
+
try {
|
|
380
|
+
logger.info(`Refreshing API contract context after offline reconciliation...`);
|
|
381
|
+
cached.context.apiContract = await extractApiContractContext(cached.context);
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
logger.warn(`Failed to refresh API contract context during reconciliation: ${err instanceof Error ? err.message : String(err)}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// 6. Update metadata and save
|
|
388
|
+
cached.timestamp = Date.now();
|
|
389
|
+
cached.fileCount = currentFilesOnDisk.length;
|
|
390
|
+
for (const file of toUpdate) {
|
|
391
|
+
try {
|
|
392
|
+
const stats = await fs.stat(file);
|
|
393
|
+
cached.fileHashes.set(file, stats.mtimeMs);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
cached.fileHashes.delete(file);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Save updated context back to disk
|
|
400
|
+
await saveContextToDisk(projectPath, cached);
|
|
401
|
+
logger.info(`Context synchronized in ${Date.now() - startTime}ms`);
|
|
402
|
+
return cached.context;
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
logger.warn(`Failure during context reconciliation: ${error instanceof Error ? error.message : String(error)}`);
|
|
406
|
+
return cached.context;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Check if cached context is stale by sampling file modification times
|
|
411
|
+
* This is a quick check that doesn't require reading all files
|
|
412
|
+
*/
|
|
413
|
+
async function isContextStale(cached, projectPath) {
|
|
414
|
+
try {
|
|
415
|
+
// Quick check 1: See if file count changed significantly
|
|
416
|
+
// Use the same search logic as building context to ensure consistency
|
|
417
|
+
// PROTOTYPE: Only fully supported languages
|
|
418
|
+
const extensions = {
|
|
419
|
+
javascript: [".js", ".jsx", ".mjs", ".cjs"],
|
|
420
|
+
typescript: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"], // Include js in ts projects
|
|
421
|
+
python: [".py"],
|
|
422
|
+
all: [".js", ".jsx", ".ts", ".tsx", ".py"], // Only TS/JS/Python for prototype
|
|
423
|
+
};
|
|
424
|
+
const exts = extensions[cached.context.language] || extensions.all;
|
|
425
|
+
const patterns = exts.map((ext) => `${projectPath}/**/*${ext}`);
|
|
426
|
+
const currentFiles = await glob(patterns, {
|
|
427
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**", "**/venv/**"],
|
|
428
|
+
nodir: true,
|
|
429
|
+
});
|
|
430
|
+
const countDiff = Math.abs(currentFiles.length - cached.fileCount);
|
|
431
|
+
if (countDiff > 5) {
|
|
432
|
+
logger.debug(`File count changed: ${cached.fileCount} -> ${currentFiles.length}`);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
// Quick check 2: Sample some files and check their modification times
|
|
436
|
+
const filesToCheck = Array.from(cached.fileHashes.keys())
|
|
437
|
+
.sort(() => Math.random() - 0.5) // Shuffle
|
|
438
|
+
.slice(0, QUICK_CHECK_SAMPLE_SIZE);
|
|
439
|
+
for (const filePath of filesToCheck) {
|
|
440
|
+
try {
|
|
441
|
+
const stats = await fs.stat(filePath);
|
|
442
|
+
const cachedMtime = cached.fileHashes.get(filePath);
|
|
443
|
+
if (cachedMtime && stats.mtimeMs > cachedMtime) {
|
|
444
|
+
logger.debug(`File modified: ${filePath}`);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// File was deleted
|
|
450
|
+
logger.debug(`File deleted: ${filePath}`);
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// Quick check 3: Check for new files in common directories
|
|
455
|
+
const commonDirs = [
|
|
456
|
+
"src",
|
|
457
|
+
"lib",
|
|
458
|
+
"app",
|
|
459
|
+
"components",
|
|
460
|
+
"pages",
|
|
461
|
+
"hooks",
|
|
462
|
+
"utils",
|
|
463
|
+
];
|
|
464
|
+
for (const dir of commonDirs) {
|
|
465
|
+
const dirPath = path.join(projectPath, dir);
|
|
466
|
+
try {
|
|
467
|
+
const dirStats = await fs.stat(dirPath);
|
|
468
|
+
// If directory was modified after cache, might have new files
|
|
469
|
+
if (dirStats.mtimeMs > cached.timestamp) {
|
|
470
|
+
const dirFiles = await glob(`${dirPath}/**/*.{ts,tsx,js,jsx,py}`, {
|
|
471
|
+
nodir: true,
|
|
472
|
+
});
|
|
473
|
+
for (const file of dirFiles.slice(0, 5)) {
|
|
474
|
+
if (!cached.fileHashes.has(file)) {
|
|
475
|
+
logger.debug(`New file detected: ${file}`);
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
// Directory doesn't exist, that's fine
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
logger.warn(`Error checking cache staleness: ${error}`);
|
|
489
|
+
return true; // Assume stale on error
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Invalidate context cache for a project
|
|
494
|
+
*/
|
|
495
|
+
export function invalidateContext(projectPath) {
|
|
496
|
+
for (const key of contextCache.keys()) {
|
|
497
|
+
if (key.startsWith(projectPath)) {
|
|
498
|
+
contextCache.delete(key);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Clear all cached contexts
|
|
504
|
+
*/
|
|
505
|
+
export function clearContextCache() {
|
|
506
|
+
contextCache.clear();
|
|
507
|
+
}
|
|
508
|
+
// ============================================================================
|
|
509
|
+
// Context Building
|
|
510
|
+
// ============================================================================
|
|
511
|
+
async function buildProjectContext(projectPath, options) {
|
|
512
|
+
const { language, includeTests, maxFiles } = options;
|
|
513
|
+
// Initialize context
|
|
514
|
+
const context = {
|
|
515
|
+
projectPath,
|
|
516
|
+
language,
|
|
517
|
+
buildTime: new Date().toISOString(),
|
|
518
|
+
totalFiles: 0,
|
|
519
|
+
files: new Map(),
|
|
520
|
+
symbolIndex: new Map(),
|
|
521
|
+
dependencies: [],
|
|
522
|
+
importGraph: new Map(),
|
|
523
|
+
reverseImportGraph: new Map(),
|
|
524
|
+
keywordIndex: new Map(),
|
|
525
|
+
externalDependencies: new Set(),
|
|
526
|
+
entryPoints: [],
|
|
527
|
+
frameworks: [],
|
|
528
|
+
};
|
|
529
|
+
// Find source files (excluding tests if requested)
|
|
530
|
+
const files = await findProjectFiles(projectPath, language, includeTests);
|
|
531
|
+
logger.info(`Found ${files.length} source files (${language}) to analyze`);
|
|
532
|
+
const filesToProcess = files.slice(0, maxFiles);
|
|
533
|
+
// Always find test files separately for import tracking (dead code detection)
|
|
534
|
+
// This ensures exports used only in tests aren't flagged as dead code
|
|
535
|
+
let testFiles = [];
|
|
536
|
+
if (!includeTests) {
|
|
537
|
+
testFiles = await findTestFiles(projectPath, language);
|
|
538
|
+
}
|
|
539
|
+
// Detect frameworks (supports multiple for full-stack projects)
|
|
540
|
+
context.frameworks = await detectFrameworks(projectPath, filesToProcess);
|
|
541
|
+
// Process each file
|
|
542
|
+
for (let i = 0; i < filesToProcess.length; i++) {
|
|
543
|
+
const filePath = filesToProcess[i];
|
|
544
|
+
try {
|
|
545
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
546
|
+
const stats = await fs.stat(filePath);
|
|
547
|
+
const fileInfo = analyzeFile(filePath, content, projectPath, context.frameworks);
|
|
548
|
+
fileInfo.lastModified = stats.mtimeMs;
|
|
549
|
+
context.files.set(filePath, fileInfo);
|
|
550
|
+
// Build symbol index
|
|
551
|
+
for (const symbol of fileInfo.symbols) {
|
|
552
|
+
if (!context.symbolIndex.has(symbol.name)) {
|
|
553
|
+
context.symbolIndex.set(symbol.name, []);
|
|
554
|
+
}
|
|
555
|
+
context.symbolIndex.get(symbol.name).push({ file: filePath, symbol });
|
|
556
|
+
}
|
|
557
|
+
// Build keyword index
|
|
558
|
+
for (const keyword of fileInfo.keywords) {
|
|
559
|
+
if (!context.keywordIndex.has(keyword)) {
|
|
560
|
+
context.keywordIndex.set(keyword, []);
|
|
561
|
+
}
|
|
562
|
+
context.keywordIndex.get(keyword).push(filePath);
|
|
563
|
+
}
|
|
564
|
+
// Track external dependencies
|
|
565
|
+
for (const imp of fileInfo.imports) {
|
|
566
|
+
if (imp.isExternal) {
|
|
567
|
+
context.externalDependencies.add(imp.source);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Track entry points
|
|
571
|
+
if (fileInfo.isEntryPoint) {
|
|
572
|
+
context.entryPoints.push(filePath);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
catch (err) {
|
|
576
|
+
// Skip unreadable files
|
|
577
|
+
}
|
|
578
|
+
// Yield to event loop every 5 files to allow MCP requests to be processed
|
|
579
|
+
if (i % 5 === 0 && i > 0) {
|
|
580
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
581
|
+
}
|
|
582
|
+
// Log progress periodically to avoid "stuck" feeling
|
|
583
|
+
if (i % 50 === 0 && i > 0) {
|
|
584
|
+
logger.info(`Context build progress: ${i}/${filesToProcess.length} files analyzed`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// Process test files for import tracking only (not for symbol indexing)
|
|
588
|
+
// This ensures exports used only in tests aren't flagged as dead code
|
|
589
|
+
for (let i = 0; i < testFiles.length; i++) {
|
|
590
|
+
const testFilePath = testFiles[i];
|
|
591
|
+
try {
|
|
592
|
+
const content = await fs.readFile(testFilePath, "utf-8");
|
|
593
|
+
const stats = await fs.stat(testFilePath);
|
|
594
|
+
const fileInfo = analyzeFile(testFilePath, content, projectPath, context.frameworks);
|
|
595
|
+
fileInfo.lastModified = stats.mtimeMs;
|
|
596
|
+
fileInfo.isTest = true; // Ensure it's marked as test
|
|
597
|
+
// Add to files map (needed for dependency graph building)
|
|
598
|
+
context.files.set(testFilePath, fileInfo);
|
|
599
|
+
// DON'T add symbols to symbolIndex - we don't want to scan test code for issues
|
|
600
|
+
// DON'T add keywords - not needed for test files
|
|
601
|
+
// DON'T track entry points - test files aren't entry points
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
// Skip unreadable files
|
|
605
|
+
}
|
|
606
|
+
// Yield to event loop every 5 files to allow MCP requests to be processed
|
|
607
|
+
if (i % 5 === 0 && i > 0) {
|
|
608
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// Build dependency graph (second pass after all files indexed)
|
|
612
|
+
// Use async version to read tsconfig.json for path aliases
|
|
613
|
+
await buildDependencyGraphAsync(context);
|
|
614
|
+
// Build symbol-level dependency graph (async/yielding)
|
|
615
|
+
context.symbolGraph = await buildSymbolGraph(context, {
|
|
616
|
+
includeCallRelationships: true,
|
|
617
|
+
includeCoOccurrence: true,
|
|
618
|
+
minCoOccurrenceCount: 2,
|
|
619
|
+
});
|
|
620
|
+
// Extract API Contract information (frontend/backend alignment)
|
|
621
|
+
// This integrates API Contract Guardian into the existing context system
|
|
622
|
+
// LAZY LOADING: Only build API contract context if both frontend and backend detected
|
|
623
|
+
try {
|
|
624
|
+
const hasFrontend = detectFrontendPresence(context);
|
|
625
|
+
const hasBackend = detectBackendPresence(context);
|
|
626
|
+
if (hasFrontend && hasBackend) {
|
|
627
|
+
logger.info("Full-stack project detected - building API contract context...");
|
|
628
|
+
const apiContractStart = Date.now();
|
|
629
|
+
context.apiContract = await extractApiContractContext(context);
|
|
630
|
+
logger.info(`API contract context built in ${Date.now() - apiContractStart}ms`);
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
logger.info(`${hasFrontend ? 'Frontend' : hasBackend ? 'Backend' : 'Unknown'}-only project - skipping API contract context`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
logger.warn("Failed to extract API Contract context:", error);
|
|
638
|
+
// Don't fail the entire context build if API Contract extraction fails
|
|
639
|
+
}
|
|
640
|
+
context.totalFiles = context.files.size;
|
|
641
|
+
return context;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Update a single file's information within an existing ProjectContext
|
|
645
|
+
*/
|
|
646
|
+
async function updateFileInContext(context, filePath, projectPath, skipGraphRebuild = false) {
|
|
647
|
+
// 1. Remove old data for this file
|
|
648
|
+
context.files.delete(filePath);
|
|
649
|
+
// Remove from symbol index
|
|
650
|
+
for (const [symbolName, infoArray] of context.symbolIndex) {
|
|
651
|
+
const filtered = infoArray.filter(item => item.file !== filePath);
|
|
652
|
+
if (filtered.length === 0) {
|
|
653
|
+
context.symbolIndex.delete(symbolName);
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
context.symbolIndex.set(symbolName, filtered);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Remove from keyword index
|
|
660
|
+
for (const [keyword, fileList] of context.keywordIndex) {
|
|
661
|
+
const filtered = fileList.filter(f => f !== filePath);
|
|
662
|
+
if (filtered.length === 0) {
|
|
663
|
+
context.keywordIndex.delete(keyword);
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
context.keywordIndex.set(keyword, filtered);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Remove dependencies originating from this file AND pointing to this file
|
|
670
|
+
context.dependencies = context.dependencies.filter(d => d.from !== filePath && d.to !== filePath);
|
|
671
|
+
// Update reverse import graph: remove this file from everyone it imported
|
|
672
|
+
const oldImports = context.importGraph.get(filePath) || [];
|
|
673
|
+
for (const impPath of oldImports) {
|
|
674
|
+
const reverse = context.reverseImportGraph.get(impPath) || [];
|
|
675
|
+
context.reverseImportGraph.set(impPath, reverse.filter(f => f !== filePath));
|
|
676
|
+
}
|
|
677
|
+
context.importGraph.delete(filePath);
|
|
678
|
+
// Clean up files that imported the deleted file:
|
|
679
|
+
// remove the deleted file from their importGraph entries
|
|
680
|
+
const importersOfDeleted = context.reverseImportGraph.get(filePath) || [];
|
|
681
|
+
for (const importerPath of importersOfDeleted) {
|
|
682
|
+
const importerImports = context.importGraph.get(importerPath);
|
|
683
|
+
if (importerImports) {
|
|
684
|
+
context.importGraph.set(importerPath, importerImports.filter(f => f !== filePath));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// Remove the deleted file's own reverse import graph entry
|
|
688
|
+
context.reverseImportGraph.delete(filePath);
|
|
689
|
+
// Remove from entry points
|
|
690
|
+
context.entryPoints = context.entryPoints.filter(f => f !== filePath);
|
|
691
|
+
// 2. Re-analyze if file still exists
|
|
692
|
+
try {
|
|
693
|
+
if (fsSync.existsSync(filePath)) {
|
|
694
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
695
|
+
const stats = await fs.stat(filePath);
|
|
696
|
+
const fileInfo = analyzeFile(filePath, content, projectPath, context.frameworks);
|
|
697
|
+
fileInfo.lastModified = stats.mtimeMs;
|
|
698
|
+
// Ensure it's marked correctly if it's a test file
|
|
699
|
+
const isTestDir = filePath.includes("/test/") || filePath.includes("/tests/") || filePath.includes("__tests__");
|
|
700
|
+
const isTestFile = filePath.match(/\.(test|spec)\.[^.]+$/);
|
|
701
|
+
if (isTestDir || isTestFile) {
|
|
702
|
+
fileInfo.isTest = true;
|
|
703
|
+
}
|
|
704
|
+
context.files.set(filePath, fileInfo);
|
|
705
|
+
// Add to symbol index (only if not a test file, matching buildProjectContext logic)
|
|
706
|
+
if (!fileInfo.isTest) {
|
|
707
|
+
for (const symbol of fileInfo.symbols) {
|
|
708
|
+
if (!context.symbolIndex.has(symbol.name)) {
|
|
709
|
+
context.symbolIndex.set(symbol.name, []);
|
|
710
|
+
}
|
|
711
|
+
context.symbolIndex.get(symbol.name).push({ file: filePath, symbol });
|
|
712
|
+
}
|
|
713
|
+
// Add keywords
|
|
714
|
+
for (const keyword of fileInfo.keywords) {
|
|
715
|
+
if (!context.keywordIndex.has(keyword)) {
|
|
716
|
+
context.keywordIndex.set(keyword, []);
|
|
717
|
+
}
|
|
718
|
+
context.keywordIndex.get(keyword).push(filePath);
|
|
719
|
+
}
|
|
720
|
+
// Track entry points
|
|
721
|
+
if (fileInfo.isEntryPoint) {
|
|
722
|
+
context.entryPoints.push(filePath);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// Track external dependencies
|
|
726
|
+
for (const imp of fileInfo.imports) {
|
|
727
|
+
if (imp.isExternal) {
|
|
728
|
+
context.externalDependencies.add(imp.source);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// 3. Re-resolve dependencies for this file
|
|
732
|
+
const allFiles = Array.from(context.files.keys());
|
|
733
|
+
const pathAliases = await detectPathAliasesAsync(context.projectPath, allFiles);
|
|
734
|
+
const fileImports = [];
|
|
735
|
+
for (const imp of fileInfo.imports) {
|
|
736
|
+
let resolved = null;
|
|
737
|
+
if (imp.isRelative) {
|
|
738
|
+
resolved = resolveImport(imp.source, filePath, allFiles);
|
|
739
|
+
}
|
|
740
|
+
else if (!imp.isExternal && fileInfo.language === "python") {
|
|
741
|
+
resolved = resolvePythonImport(imp.source, context.projectPath, allFiles);
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
resolved = resolvePathAlias(imp.source, pathAliases, allFiles);
|
|
745
|
+
}
|
|
746
|
+
if (resolved) {
|
|
747
|
+
fileImports.push(resolved);
|
|
748
|
+
context.dependencies.push({
|
|
749
|
+
from: filePath,
|
|
750
|
+
to: resolved,
|
|
751
|
+
importedSymbols: [...imp.namedImports, imp.defaultImport].filter(Boolean),
|
|
752
|
+
});
|
|
753
|
+
// Update reverse graph
|
|
754
|
+
if (!context.reverseImportGraph.has(resolved)) {
|
|
755
|
+
context.reverseImportGraph.set(resolved, []);
|
|
756
|
+
}
|
|
757
|
+
context.reverseImportGraph.get(resolved).push(filePath);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
context.importGraph.set(filePath, fileImports);
|
|
761
|
+
// 3b. Re-resolve OTHER files' unresolved imports that might now point to this file.
|
|
762
|
+
// When a new file is created during vibecoding (e.g., OrbitMap.tsx), existing files
|
|
763
|
+
// (e.g., App.tsx) may have had unresolved imports to it. We check if any other file
|
|
764
|
+
// has a relative import whose resolution target matches this new file's path.
|
|
765
|
+
const fileBaseName = path.basename(filePath).replace(/\.[^.]+$/, ""); // e.g., "OrbitMap"
|
|
766
|
+
for (const [otherPath, otherInfo] of context.files) {
|
|
767
|
+
if (otherPath === filePath)
|
|
768
|
+
continue;
|
|
769
|
+
// Quick check: does this file have any import whose source contains our filename?
|
|
770
|
+
const hasRelevantImport = otherInfo.imports.some(imp => imp.isRelative && imp.source.includes(fileBaseName));
|
|
771
|
+
if (!hasRelevantImport)
|
|
772
|
+
continue;
|
|
773
|
+
// Check if any of this file's imports should resolve to the new/modified file
|
|
774
|
+
for (const imp of otherInfo.imports) {
|
|
775
|
+
if (!imp.isRelative)
|
|
776
|
+
continue;
|
|
777
|
+
const resolved = resolveImport(imp.source, otherPath, allFiles);
|
|
778
|
+
if (resolved !== filePath)
|
|
779
|
+
continue;
|
|
780
|
+
// This import resolves to our file! Check if this edge already exists.
|
|
781
|
+
const existingEdge = context.dependencies.some(d => d.from === otherPath && d.to === filePath);
|
|
782
|
+
if (existingEdge)
|
|
783
|
+
continue;
|
|
784
|
+
// Add the missing dependency edge
|
|
785
|
+
const importedSymbols = [...imp.namedImports, imp.defaultImport].filter(Boolean);
|
|
786
|
+
context.dependencies.push({
|
|
787
|
+
from: otherPath,
|
|
788
|
+
to: filePath,
|
|
789
|
+
importedSymbols,
|
|
790
|
+
});
|
|
791
|
+
// Update import graph
|
|
792
|
+
const otherImports = context.importGraph.get(otherPath) || [];
|
|
793
|
+
if (!otherImports.includes(filePath)) {
|
|
794
|
+
otherImports.push(filePath);
|
|
795
|
+
context.importGraph.set(otherPath, otherImports);
|
|
796
|
+
}
|
|
797
|
+
// Update reverse import graph
|
|
798
|
+
if (!context.reverseImportGraph.has(filePath)) {
|
|
799
|
+
context.reverseImportGraph.set(filePath, []);
|
|
800
|
+
}
|
|
801
|
+
const reverseList = context.reverseImportGraph.get(filePath);
|
|
802
|
+
if (!reverseList.includes(otherPath)) {
|
|
803
|
+
reverseList.push(otherPath);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
logger.warn(`Failed to incrementally update context for ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
811
|
+
}
|
|
812
|
+
// 4. Rebuild symbol graph
|
|
813
|
+
if (!skipGraphRebuild) {
|
|
814
|
+
context.symbolGraph = await buildSymbolGraph(context, {
|
|
815
|
+
includeCallRelationships: true,
|
|
816
|
+
includeCoOccurrence: true,
|
|
817
|
+
minCoOccurrenceCount: 2,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
context.buildTime = new Date().toISOString();
|
|
821
|
+
context.totalFiles = context.files.size;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Helper to detect common source root directories in a project.
|
|
825
|
+
* This makes the tool smarter about where to look for code.
|
|
826
|
+
*/
|
|
827
|
+
function detectRootSourceDirs(projectPath, language) {
|
|
828
|
+
// Include additional common top-level folders used in smaller repos and fixtures.
|
|
829
|
+
// Notably, many projects (and our tests) place shared helpers in ./utils.
|
|
830
|
+
const commonDirs = language === "python"
|
|
831
|
+
? ["app", "src", "server", "core", "api"]
|
|
832
|
+
: [
|
|
833
|
+
"src",
|
|
834
|
+
"app",
|
|
835
|
+
"pages",
|
|
836
|
+
"lib",
|
|
837
|
+
"components",
|
|
838
|
+
"actions",
|
|
839
|
+
"services",
|
|
840
|
+
"utils",
|
|
841
|
+
];
|
|
842
|
+
const found = [];
|
|
843
|
+
for (const dir of commonDirs) {
|
|
844
|
+
const fullPath = path.join(projectPath, dir);
|
|
845
|
+
if (fsSync.existsSync(fullPath)) {
|
|
846
|
+
found.push(dir);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// If no common dirs found, return the root
|
|
850
|
+
return found.length > 0 ? found : ["."];
|
|
851
|
+
}
|
|
852
|
+
async function findProjectFiles(projectPath, language, includeTests) {
|
|
853
|
+
// PROTOTYPE: Only fully supported languages
|
|
854
|
+
// TODO: Add support for Go, Java, and other languages in future versions
|
|
855
|
+
const extensions = {
|
|
856
|
+
javascript: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"], // Include TS in JS projects for modern interop
|
|
857
|
+
typescript: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"], // Include JS in TS projects for Vite/React
|
|
858
|
+
python: [".py"],
|
|
859
|
+
all: [".js", ".jsx", ".ts", ".tsx", ".py"], // Only TS/JS/Python for prototype
|
|
860
|
+
};
|
|
861
|
+
const exts = extensions[language] || extensions.all;
|
|
862
|
+
// Intelligence: If running on project root, try to narrow down to common source dirs
|
|
863
|
+
const sourceDirs = detectRootSourceDirs(projectPath, language);
|
|
864
|
+
const patterns = [];
|
|
865
|
+
for (const dir of sourceDirs) {
|
|
866
|
+
for (const ext of exts) {
|
|
867
|
+
// Use standard glob pattern from detected side dirs
|
|
868
|
+
patterns.push(path.join(projectPath, dir, `**/*${ext}`));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const excludes = [
|
|
872
|
+
"**/node_modules/**",
|
|
873
|
+
"**/venv/**",
|
|
874
|
+
"**/.venv/**",
|
|
875
|
+
"**/env/**",
|
|
876
|
+
"**/__pycache__/**",
|
|
877
|
+
"**/dist/**",
|
|
878
|
+
"**/build/**",
|
|
879
|
+
"**/.next/**",
|
|
880
|
+
"**/coverage/**",
|
|
881
|
+
"**/.git/**",
|
|
882
|
+
"**/vendor/**",
|
|
883
|
+
"**/*.min.js",
|
|
884
|
+
...getExcludePatternsForPath(projectPath),
|
|
885
|
+
];
|
|
886
|
+
if (!includeTests) {
|
|
887
|
+
excludes.push("**/*.test.*", "**/*.spec.*", "**/test/**", "**/__tests__/**");
|
|
888
|
+
}
|
|
889
|
+
const files = await glob(patterns, {
|
|
890
|
+
ignore: excludes,
|
|
891
|
+
nodir: true,
|
|
892
|
+
absolute: true,
|
|
893
|
+
});
|
|
894
|
+
return filterExcludedFiles(files);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Find test files only - used for import tracking when includeTests is false
|
|
898
|
+
* This ensures exports used only in tests aren't flagged as dead code
|
|
899
|
+
*/
|
|
900
|
+
async function findTestFiles(projectPath, language) {
|
|
901
|
+
const extensions = {
|
|
902
|
+
javascript: [".js", ".jsx", ".mjs", ".cjs"],
|
|
903
|
+
typescript: [".ts", ".tsx", ".mts", ".cts"],
|
|
904
|
+
python: [".py"],
|
|
905
|
+
go: [".go"],
|
|
906
|
+
java: [".java"],
|
|
907
|
+
all: [".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".java"],
|
|
908
|
+
};
|
|
909
|
+
const exts = extensions[language] || extensions.all;
|
|
910
|
+
// Only look for test files
|
|
911
|
+
const testPatterns = [
|
|
912
|
+
...exts.map((ext) => `${projectPath}/**/*.test${ext}`),
|
|
913
|
+
...exts.map((ext) => `${projectPath}/**/*.spec${ext}`),
|
|
914
|
+
...exts.map((ext) => `${projectPath}/**/test/**/*${ext}`),
|
|
915
|
+
...exts.map((ext) => `${projectPath}/**/__tests__/**/*${ext}`),
|
|
916
|
+
...exts.map((ext) => `${projectPath}/**/tests/**/*${ext}`),
|
|
917
|
+
];
|
|
918
|
+
const excludes = [
|
|
919
|
+
"**/node_modules/**",
|
|
920
|
+
"**/venv/**",
|
|
921
|
+
"**/.venv/**",
|
|
922
|
+
"**/env/**",
|
|
923
|
+
"**/__pycache__/**",
|
|
924
|
+
"**/dist/**",
|
|
925
|
+
"**/build/**",
|
|
926
|
+
"**/.next/**",
|
|
927
|
+
"**/coverage/**",
|
|
928
|
+
"**/.git/**",
|
|
929
|
+
"**/vendor/**",
|
|
930
|
+
];
|
|
931
|
+
const files = await glob(testPatterns, {
|
|
932
|
+
ignore: excludes,
|
|
933
|
+
nodir: true,
|
|
934
|
+
absolute: true,
|
|
935
|
+
});
|
|
936
|
+
return filterExcludedFiles(files);
|
|
937
|
+
}
|
|
938
|
+
// ============================================================================
|
|
939
|
+
// File Analysis
|
|
940
|
+
// ============================================================================
|
|
941
|
+
function analyzeFile(filePath, content, projectPath, frameworks) {
|
|
942
|
+
const ext = path.extname(filePath);
|
|
943
|
+
const language = getLanguageFromExt(ext);
|
|
944
|
+
const relativePath = path.relative(projectPath, filePath);
|
|
945
|
+
const fileName = path.basename(filePath);
|
|
946
|
+
const fileInfo = {
|
|
947
|
+
path: filePath,
|
|
948
|
+
relativePath,
|
|
949
|
+
language,
|
|
950
|
+
size: content.length,
|
|
951
|
+
symbols: [],
|
|
952
|
+
imports: [],
|
|
953
|
+
exports: [],
|
|
954
|
+
keywords: [],
|
|
955
|
+
isTest: isTestFile(filePath),
|
|
956
|
+
isConfig: isConfigFile(fileName),
|
|
957
|
+
isEntryPoint: isEntryPointFile(filePath, frameworks),
|
|
958
|
+
};
|
|
959
|
+
// Extract based on language - use AST for accurate multi-line parsing
|
|
960
|
+
if (language === "typescript" || language === "javascript") {
|
|
961
|
+
extractJSSymbolsAST(content, filePath, language, fileInfo);
|
|
962
|
+
extractJSImportsAST(content, language, fileInfo);
|
|
963
|
+
extractJSExportsRegex(content, fileInfo); // Keep regex for exports (simpler)
|
|
964
|
+
}
|
|
965
|
+
else if (language === "python") {
|
|
966
|
+
extractPythonSymbolsAST(content, filePath, fileInfo);
|
|
967
|
+
extractPythonImportsAST(content, fileInfo);
|
|
968
|
+
}
|
|
969
|
+
// Extract keywords from path and content
|
|
970
|
+
fileInfo.keywords = extractKeywords(filePath, content);
|
|
971
|
+
return fileInfo;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* AST-based JS/TS symbol extraction - handles multi-line signatures correctly
|
|
975
|
+
*/
|
|
976
|
+
function extractJSSymbolsAST(content, filePath, language, fileInfo) {
|
|
977
|
+
try {
|
|
978
|
+
const astSymbols = extractSymbolsAST(content, filePath, language);
|
|
979
|
+
for (const sym of astSymbols) {
|
|
980
|
+
// Map AST symbol types to FileInfo symbol kinds
|
|
981
|
+
let kind;
|
|
982
|
+
switch (sym.type) {
|
|
983
|
+
case "function":
|
|
984
|
+
// Detect hooks and components
|
|
985
|
+
if (sym.name.startsWith("use") &&
|
|
986
|
+
sym.name[3]?.toUpperCase() === sym.name[3]) {
|
|
987
|
+
kind = "hook";
|
|
988
|
+
}
|
|
989
|
+
else if (/^[A-Z]/.test(sym.name)) {
|
|
990
|
+
kind = "component";
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
kind = "function";
|
|
994
|
+
}
|
|
995
|
+
break;
|
|
996
|
+
case "class":
|
|
997
|
+
kind = "class";
|
|
998
|
+
break;
|
|
999
|
+
case "method":
|
|
1000
|
+
kind = "function"; // Methods are stored as functions with scope info
|
|
1001
|
+
break;
|
|
1002
|
+
case "variable":
|
|
1003
|
+
kind = "variable";
|
|
1004
|
+
break;
|
|
1005
|
+
case "interface":
|
|
1006
|
+
kind = "interface";
|
|
1007
|
+
break;
|
|
1008
|
+
case "type":
|
|
1009
|
+
kind = "type";
|
|
1010
|
+
break;
|
|
1011
|
+
default:
|
|
1012
|
+
kind = "function";
|
|
1013
|
+
}
|
|
1014
|
+
fileInfo.symbols.push({
|
|
1015
|
+
name: sym.name,
|
|
1016
|
+
kind,
|
|
1017
|
+
line: sym.line,
|
|
1018
|
+
exported: sym.isExported ?? false,
|
|
1019
|
+
async: sym.isAsync,
|
|
1020
|
+
params: sym.params?.map((p) => ({ name: p })),
|
|
1021
|
+
returnType: sym.returnType,
|
|
1022
|
+
scope: sym.scope,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
// Also extract interfaces, types, and enums using regex (AST doesn't cover these well)
|
|
1026
|
+
extractJSTypesRegex(content, fileInfo);
|
|
1027
|
+
}
|
|
1028
|
+
catch (err) {
|
|
1029
|
+
// Fallback to regex if AST parsing fails
|
|
1030
|
+
logger.debug(`AST parsing failed for ${filePath}, falling back to regex`);
|
|
1031
|
+
extractJSSymbolsRegex(content, fileInfo);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Extract TypeScript-specific types (interfaces, types, enums) using regex
|
|
1036
|
+
* These aren't well-supported by tree-sitter-javascript
|
|
1037
|
+
*
|
|
1038
|
+
* NOTE: This now only adds symbols that weren't already extracted by AST parsing
|
|
1039
|
+
*/
|
|
1040
|
+
function extractJSTypesRegex(content, fileInfo) {
|
|
1041
|
+
const lines = content.split("\n");
|
|
1042
|
+
// Helper to check if symbol already exists
|
|
1043
|
+
const symbolExists = (name) => fileInfo.symbols.some(s => s.name === name);
|
|
1044
|
+
lines.forEach((line, idx) => {
|
|
1045
|
+
const lineNum = idx + 1;
|
|
1046
|
+
// Interfaces - only add if not already extracted by AST
|
|
1047
|
+
const interfaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
|
|
1048
|
+
if (interfaceMatch && !symbolExists(interfaceMatch[1])) {
|
|
1049
|
+
fileInfo.symbols.push({
|
|
1050
|
+
name: interfaceMatch[1],
|
|
1051
|
+
kind: "interface",
|
|
1052
|
+
line: lineNum,
|
|
1053
|
+
exported: line.includes("export"),
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
// Types - only add if not already extracted by AST
|
|
1057
|
+
const typeMatch = line.match(/(?:export\s+)?type\s+(\w+)\s*(?:<[^>]+>)?\s*=/);
|
|
1058
|
+
if (typeMatch && !symbolExists(typeMatch[1])) {
|
|
1059
|
+
fileInfo.symbols.push({
|
|
1060
|
+
name: typeMatch[1],
|
|
1061
|
+
kind: "type",
|
|
1062
|
+
line: lineNum,
|
|
1063
|
+
exported: line.includes("export"),
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
// Enums - only add if not already extracted by AST
|
|
1067
|
+
const enumMatch = line.match(/(?:export\s+)?enum\s+(\w+)/);
|
|
1068
|
+
if (enumMatch && !symbolExists(enumMatch[1])) {
|
|
1069
|
+
fileInfo.symbols.push({
|
|
1070
|
+
name: enumMatch[1],
|
|
1071
|
+
kind: "enum",
|
|
1072
|
+
line: lineNum,
|
|
1073
|
+
exported: line.includes("export"),
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Fallback regex-based extraction for when AST fails
|
|
1080
|
+
*/
|
|
1081
|
+
function extractJSSymbolsRegex(content, fileInfo) {
|
|
1082
|
+
const lines = content.split("\n");
|
|
1083
|
+
lines.forEach((line, idx) => {
|
|
1084
|
+
const lineNum = idx + 1;
|
|
1085
|
+
const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*(?:<[^>]+>)?\s*\(([^)]*)\)(?:\s*:\s*([^{]+))?/);
|
|
1086
|
+
if (funcMatch) {
|
|
1087
|
+
fileInfo.symbols.push({
|
|
1088
|
+
name: funcMatch[1],
|
|
1089
|
+
kind: "function",
|
|
1090
|
+
line: lineNum,
|
|
1091
|
+
exported: line.includes("export"),
|
|
1092
|
+
async: line.includes("async"),
|
|
1093
|
+
returnType: funcMatch[3]?.trim(),
|
|
1094
|
+
params: parseParams(funcMatch[2]),
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
// Arrow functions
|
|
1098
|
+
const arrowMatch = line.match(/(?:export\s+)?const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s*)?\(/);
|
|
1099
|
+
if (arrowMatch && !fileInfo.symbols.find((s) => s.name === arrowMatch[1])) {
|
|
1100
|
+
const isHook = arrowMatch[1].startsWith("use") &&
|
|
1101
|
+
arrowMatch[1][3]?.toUpperCase() === arrowMatch[1][3];
|
|
1102
|
+
const isComponent = /^[A-Z]/.test(arrowMatch[1]);
|
|
1103
|
+
fileInfo.symbols.push({
|
|
1104
|
+
name: arrowMatch[1],
|
|
1105
|
+
kind: isHook ? "hook"
|
|
1106
|
+
: isComponent ? "component"
|
|
1107
|
+
: "function",
|
|
1108
|
+
line: lineNum,
|
|
1109
|
+
exported: line.includes("export"),
|
|
1110
|
+
async: line.includes("async"),
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
// Classes
|
|
1114
|
+
const classMatch = line.match(/(?:export\s+)?class\s+(\w+)/);
|
|
1115
|
+
if (classMatch) {
|
|
1116
|
+
fileInfo.symbols.push({
|
|
1117
|
+
name: classMatch[1],
|
|
1118
|
+
kind: "class",
|
|
1119
|
+
line: lineNum,
|
|
1120
|
+
exported: line.includes("export"),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
// Interfaces
|
|
1124
|
+
const interfaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
|
|
1125
|
+
if (interfaceMatch) {
|
|
1126
|
+
fileInfo.symbols.push({
|
|
1127
|
+
name: interfaceMatch[1],
|
|
1128
|
+
kind: "interface",
|
|
1129
|
+
line: lineNum,
|
|
1130
|
+
exported: line.includes("export"),
|
|
1131
|
+
});
|
|
1132
|
+
}
|
|
1133
|
+
// Types
|
|
1134
|
+
const typeMatch = line.match(/(?:export\s+)?type\s+(\w+)\s*(?:<[^>]+>)?\s*=/);
|
|
1135
|
+
if (typeMatch) {
|
|
1136
|
+
fileInfo.symbols.push({
|
|
1137
|
+
name: typeMatch[1],
|
|
1138
|
+
kind: "type",
|
|
1139
|
+
line: lineNum,
|
|
1140
|
+
exported: line.includes("export"),
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
// Enums
|
|
1144
|
+
const enumMatch = line.match(/(?:export\s+)?enum\s+(\w+)/);
|
|
1145
|
+
if (enumMatch) {
|
|
1146
|
+
fileInfo.symbols.push({
|
|
1147
|
+
name: enumMatch[1],
|
|
1148
|
+
kind: "enum",
|
|
1149
|
+
line: lineNum,
|
|
1150
|
+
exported: line.includes("export"),
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
// Exported variables/constants
|
|
1154
|
+
const varMatch = line.match(/export\s+(?:const|let|var)\s+(\w+)/);
|
|
1155
|
+
if (varMatch && !fileInfo.symbols.find((s) => s.name === varMatch[1])) {
|
|
1156
|
+
fileInfo.symbols.push({
|
|
1157
|
+
name: varMatch[1],
|
|
1158
|
+
kind: "variable",
|
|
1159
|
+
line: lineNum,
|
|
1160
|
+
exported: true,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* AST-based JS/TS import extraction
|
|
1167
|
+
*/
|
|
1168
|
+
function extractJSImportsAST(content, language, fileInfo) {
|
|
1169
|
+
try {
|
|
1170
|
+
const astImports = extractImportsASTWithOptions(content, language, {
|
|
1171
|
+
filePath: fileInfo.path,
|
|
1172
|
+
});
|
|
1173
|
+
for (const imp of astImports) {
|
|
1174
|
+
fileInfo.imports.push({
|
|
1175
|
+
source: imp.module,
|
|
1176
|
+
isRelative: imp.module.startsWith("."),
|
|
1177
|
+
isExternal: imp.isExternal,
|
|
1178
|
+
defaultImport: imp.names.find((n) => n.imported === "default")?.local,
|
|
1179
|
+
namespaceImport: imp.names.find((n) => n.imported === "*")?.local,
|
|
1180
|
+
namedImports: imp.names
|
|
1181
|
+
.filter((n) => n.imported !== "default" && n.imported !== "*")
|
|
1182
|
+
.map((n) => n.local),
|
|
1183
|
+
line: imp.line,
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
catch (err) {
|
|
1188
|
+
// Fallback to regex if AST parsing fails
|
|
1189
|
+
extractJSImportsRegex(content, fileInfo);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Fallback regex-based import extraction
|
|
1194
|
+
*/
|
|
1195
|
+
function extractJSImportsRegex(content, fileInfo) {
|
|
1196
|
+
const lines = content.split("\n");
|
|
1197
|
+
lines.forEach((line, idx) => {
|
|
1198
|
+
// ES imports
|
|
1199
|
+
const importMatch = line.match(/import\s+(?:(\w+)(?:\s*,\s*)?)?(?:\{([^}]+)\})?\s*from\s*['"]([^'"]+)['"]/);
|
|
1200
|
+
if (importMatch) {
|
|
1201
|
+
const source = importMatch[3];
|
|
1202
|
+
const isRelative = source.startsWith(".");
|
|
1203
|
+
const isExternal = !isRelative &&
|
|
1204
|
+
!source.startsWith("/") &&
|
|
1205
|
+
!source.startsWith("@/") &&
|
|
1206
|
+
!source.startsWith("~/");
|
|
1207
|
+
fileInfo.imports.push({
|
|
1208
|
+
source,
|
|
1209
|
+
isRelative,
|
|
1210
|
+
isExternal,
|
|
1211
|
+
defaultImport: importMatch[1],
|
|
1212
|
+
namedImports: importMatch[2] ?
|
|
1213
|
+
importMatch[2].split(",").map((s) => s.trim().split(" ")[0])
|
|
1214
|
+
: [],
|
|
1215
|
+
line: idx + 1,
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
// Dynamic imports: await import('...') or import('...')
|
|
1219
|
+
const dynamicImportMatch = line.match(/(?:await\s+)?import\s*\(\s*['"]([^'"]+)['"]\s*\)/);
|
|
1220
|
+
if (dynamicImportMatch) {
|
|
1221
|
+
const source = dynamicImportMatch[1];
|
|
1222
|
+
const isRelative = source.startsWith(".");
|
|
1223
|
+
const isExternal = !isRelative &&
|
|
1224
|
+
!source.startsWith("/") &&
|
|
1225
|
+
!source.startsWith("@/") &&
|
|
1226
|
+
!source.startsWith("~/");
|
|
1227
|
+
// Try to extract destructured names from the same or next line
|
|
1228
|
+
// e.g., const { foo, bar } = await import('...')
|
|
1229
|
+
const destructureMatch = line.match(/\{\s*([^}]+)\s*\}\s*=\s*(?:await\s+)?import/);
|
|
1230
|
+
const namedImports = [];
|
|
1231
|
+
if (destructureMatch) {
|
|
1232
|
+
const names = destructureMatch[1].split(",");
|
|
1233
|
+
for (const name of names) {
|
|
1234
|
+
const cleanName = name
|
|
1235
|
+
.trim()
|
|
1236
|
+
.split(/\s+as\s+/)[0]
|
|
1237
|
+
.trim();
|
|
1238
|
+
if (cleanName)
|
|
1239
|
+
namedImports.push(cleanName);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
fileInfo.imports.push({
|
|
1243
|
+
source,
|
|
1244
|
+
isRelative,
|
|
1245
|
+
isExternal,
|
|
1246
|
+
namedImports,
|
|
1247
|
+
line: idx + 1,
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
// Require
|
|
1251
|
+
const requireMatch = line.match(/(?:const|let|var)\s+(?:(\w+)|\{([^}]+)\})\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/);
|
|
1252
|
+
if (requireMatch) {
|
|
1253
|
+
const source = requireMatch[3];
|
|
1254
|
+
const isRelative = source.startsWith(".");
|
|
1255
|
+
const isExternal = !isRelative &&
|
|
1256
|
+
!source.startsWith("/") &&
|
|
1257
|
+
!source.startsWith("@/") &&
|
|
1258
|
+
!source.startsWith("~/");
|
|
1259
|
+
fileInfo.imports.push({
|
|
1260
|
+
source,
|
|
1261
|
+
isRelative,
|
|
1262
|
+
isExternal,
|
|
1263
|
+
defaultImport: requireMatch[1],
|
|
1264
|
+
namedImports: requireMatch[2] ?
|
|
1265
|
+
requireMatch[2].split(",").map((s) => s.trim())
|
|
1266
|
+
: [],
|
|
1267
|
+
line: idx + 1,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Regex-based export extraction (simple enough that AST isn't needed)
|
|
1274
|
+
*/
|
|
1275
|
+
function extractJSExportsRegex(content, fileInfo) {
|
|
1276
|
+
const lines = content.split("\n");
|
|
1277
|
+
lines.forEach((line, idx) => {
|
|
1278
|
+
// Named exports
|
|
1279
|
+
const namedMatch = line.match(/export\s+(?:const|let|var|function|class|interface|type|enum)\s+(\w+)/);
|
|
1280
|
+
if (namedMatch) {
|
|
1281
|
+
fileInfo.exports.push({
|
|
1282
|
+
name: namedMatch[1],
|
|
1283
|
+
kind: "named",
|
|
1284
|
+
isDefault: false,
|
|
1285
|
+
line: idx + 1,
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
// Default export
|
|
1289
|
+
const defaultMatch = line.match(/export\s+default\s+(?:function\s+)?(\w+)?/);
|
|
1290
|
+
if (defaultMatch) {
|
|
1291
|
+
fileInfo.exports.push({
|
|
1292
|
+
name: defaultMatch[1] || "default",
|
|
1293
|
+
kind: "default",
|
|
1294
|
+
isDefault: true,
|
|
1295
|
+
line: idx + 1,
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
/**
|
|
1301
|
+
* AST-based Python symbol extraction
|
|
1302
|
+
*/
|
|
1303
|
+
function extractPythonSymbolsAST(content, filePath, fileInfo) {
|
|
1304
|
+
try {
|
|
1305
|
+
const astSymbols = extractSymbolsAST(content, filePath, "python");
|
|
1306
|
+
for (const sym of astSymbols) {
|
|
1307
|
+
let kind;
|
|
1308
|
+
switch (sym.type) {
|
|
1309
|
+
case "class":
|
|
1310
|
+
kind = "class";
|
|
1311
|
+
break;
|
|
1312
|
+
case "method":
|
|
1313
|
+
kind = "method";
|
|
1314
|
+
break;
|
|
1315
|
+
case "variable":
|
|
1316
|
+
kind = "variable";
|
|
1317
|
+
break;
|
|
1318
|
+
default:
|
|
1319
|
+
kind = "function";
|
|
1320
|
+
}
|
|
1321
|
+
// In Python, names not starting with _ are public (exported)
|
|
1322
|
+
const isExported = sym.isExported ?? !sym.name.startsWith("_");
|
|
1323
|
+
fileInfo.symbols.push({
|
|
1324
|
+
name: sym.name,
|
|
1325
|
+
kind,
|
|
1326
|
+
line: sym.line,
|
|
1327
|
+
exported: isExported,
|
|
1328
|
+
async: sym.isAsync,
|
|
1329
|
+
params: sym.params?.map((p) => ({ name: p })),
|
|
1330
|
+
scope: sym.scope,
|
|
1331
|
+
});
|
|
1332
|
+
// Populate exports list for Python (module-level public symbols)
|
|
1333
|
+
if (isExported && !sym.scope) {
|
|
1334
|
+
fileInfo.exports.push({
|
|
1335
|
+
name: sym.name,
|
|
1336
|
+
kind: kind,
|
|
1337
|
+
isDefault: false,
|
|
1338
|
+
line: sym.line,
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
catch (err) {
|
|
1344
|
+
// Fallback to regex if AST parsing fails
|
|
1345
|
+
extractPythonSymbolsRegex(content, fileInfo);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Fallback regex-based Python symbol extraction
|
|
1350
|
+
*/
|
|
1351
|
+
function extractPythonSymbolsRegex(content, fileInfo) {
|
|
1352
|
+
const lines = content.split("\n");
|
|
1353
|
+
lines.forEach((line, idx) => {
|
|
1354
|
+
// Functions
|
|
1355
|
+
const funcMatch = line.match(/^(?:async\s+)?def\s+(\w+)\s*\(/);
|
|
1356
|
+
if (funcMatch) {
|
|
1357
|
+
fileInfo.symbols.push({
|
|
1358
|
+
name: funcMatch[1],
|
|
1359
|
+
kind: "function",
|
|
1360
|
+
line: idx + 1,
|
|
1361
|
+
exported: !funcMatch[1].startsWith("_"),
|
|
1362
|
+
async: line.includes("async"),
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
// Classes
|
|
1366
|
+
const classMatch = line.match(/^class\s+(\w+)/);
|
|
1367
|
+
if (classMatch) {
|
|
1368
|
+
fileInfo.symbols.push({
|
|
1369
|
+
name: classMatch[1],
|
|
1370
|
+
kind: "class",
|
|
1371
|
+
line: idx + 1,
|
|
1372
|
+
exported: !classMatch[1].startsWith("_"),
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
/**
|
|
1378
|
+
* AST-based Python import extraction
|
|
1379
|
+
*/
|
|
1380
|
+
function extractPythonImportsAST(content, fileInfo) {
|
|
1381
|
+
try {
|
|
1382
|
+
const astImports = extractImportsAST(content, "python");
|
|
1383
|
+
for (const imp of astImports) {
|
|
1384
|
+
fileInfo.imports.push({
|
|
1385
|
+
source: imp.module,
|
|
1386
|
+
isRelative: imp.module.startsWith("."),
|
|
1387
|
+
isExternal: imp.isExternal,
|
|
1388
|
+
namedImports: imp.names.map((n) => n.local),
|
|
1389
|
+
defaultImport: imp.names.length === 1 ? imp.names[0].local : undefined,
|
|
1390
|
+
line: imp.line,
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
catch (err) {
|
|
1395
|
+
// Fallback to regex if AST parsing fails
|
|
1396
|
+
extractPythonImportsRegex(content, fileInfo);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Fallback regex-based Python import extraction
|
|
1401
|
+
*/
|
|
1402
|
+
function extractPythonImportsRegex(content, fileInfo) {
|
|
1403
|
+
const lines = content.split("\n");
|
|
1404
|
+
lines.forEach((line, idx) => {
|
|
1405
|
+
// from X import Y
|
|
1406
|
+
const fromMatch = line.match(/from\s+([\w.]+)\s+import\s+(.+)/);
|
|
1407
|
+
if (fromMatch) {
|
|
1408
|
+
const source = fromMatch[1];
|
|
1409
|
+
const isRelative = source.startsWith(".");
|
|
1410
|
+
const isExternal = !isRelative && !source.includes(".");
|
|
1411
|
+
fileInfo.imports.push({
|
|
1412
|
+
source,
|
|
1413
|
+
isRelative,
|
|
1414
|
+
isExternal,
|
|
1415
|
+
namedImports: fromMatch[2]
|
|
1416
|
+
.split(",")
|
|
1417
|
+
.map((s) => s.trim().split(" ")[0]),
|
|
1418
|
+
line: idx + 1,
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
// import X
|
|
1422
|
+
const importMatch = line.match(/^import\s+([\w.]+)/);
|
|
1423
|
+
if (importMatch) {
|
|
1424
|
+
const source = importMatch[1];
|
|
1425
|
+
fileInfo.imports.push({
|
|
1426
|
+
source,
|
|
1427
|
+
isRelative: false,
|
|
1428
|
+
isExternal: !source.includes("."),
|
|
1429
|
+
namedImports: [],
|
|
1430
|
+
defaultImport: source.split(".")[0],
|
|
1431
|
+
line: idx + 1,
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
// ============================================================================
|
|
1437
|
+
// Dependency Graph
|
|
1438
|
+
// ============================================================================
|
|
1439
|
+
/**
|
|
1440
|
+
* Build dependency graph with async tsconfig.json reading for path aliases
|
|
1441
|
+
*/
|
|
1442
|
+
async function buildDependencyGraphAsync(context) {
|
|
1443
|
+
const allFiles = Array.from(context.files.keys());
|
|
1444
|
+
const pathAliases = await detectPathAliasesAsync(context.projectPath, allFiles);
|
|
1445
|
+
let i = 0;
|
|
1446
|
+
for (const [filePath, fileInfo] of context.files) {
|
|
1447
|
+
i++;
|
|
1448
|
+
const imports = [];
|
|
1449
|
+
for (const imp of fileInfo.imports) {
|
|
1450
|
+
let resolved = null;
|
|
1451
|
+
if (imp.isRelative) {
|
|
1452
|
+
// Standard relative import: ./foo, ../bar, from . import x
|
|
1453
|
+
resolved = resolveImport(imp.source, filePath, allFiles);
|
|
1454
|
+
}
|
|
1455
|
+
else if (!imp.isExternal && fileInfo.language === "python") {
|
|
1456
|
+
// Python absolute import: from app.core.config import settings
|
|
1457
|
+
resolved = resolvePythonImport(imp.source, context.projectPath, allFiles);
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
// Check if it's a path alias (e.g., @/services/timeEntries, ~/utils)
|
|
1461
|
+
resolved = resolvePathAlias(imp.source, pathAliases, allFiles);
|
|
1462
|
+
}
|
|
1463
|
+
if (resolved) {
|
|
1464
|
+
imports.push(resolved);
|
|
1465
|
+
// Add dependency edge
|
|
1466
|
+
context.dependencies.push({
|
|
1467
|
+
from: filePath,
|
|
1468
|
+
to: resolved,
|
|
1469
|
+
importedSymbols: [...imp.namedImports, imp.defaultImport].filter(Boolean),
|
|
1470
|
+
});
|
|
1471
|
+
// Update reverse graph
|
|
1472
|
+
if (!context.reverseImportGraph.has(resolved)) {
|
|
1473
|
+
context.reverseImportGraph.set(resolved, []);
|
|
1474
|
+
}
|
|
1475
|
+
context.reverseImportGraph.get(resolved).push(filePath);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
context.importGraph.set(filePath, imports);
|
|
1479
|
+
// Yield every 20 files
|
|
1480
|
+
if (i % 20 === 0) {
|
|
1481
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
// Unused function - kept for potential future use
|
|
1486
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1487
|
+
function buildDependencyGraph(context) {
|
|
1488
|
+
const allFiles = Array.from(context.files.keys());
|
|
1489
|
+
// Detect path aliases from common patterns (sync version)
|
|
1490
|
+
// For async tsconfig reading, we'd need to refactor buildProjectContext
|
|
1491
|
+
const pathAliases = detectPathAliases(context.projectPath, allFiles);
|
|
1492
|
+
for (const [filePath, fileInfo] of context.files) {
|
|
1493
|
+
const imports = [];
|
|
1494
|
+
for (const imp of fileInfo.imports) {
|
|
1495
|
+
let resolved = null;
|
|
1496
|
+
if (imp.isRelative) {
|
|
1497
|
+
// Standard relative import: ./foo, ../bar
|
|
1498
|
+
resolved = resolveImport(imp.source, filePath, allFiles);
|
|
1499
|
+
}
|
|
1500
|
+
else if (!imp.isExternal && fileInfo.language === "python") {
|
|
1501
|
+
// Python absolute import: from app.core.config import settings
|
|
1502
|
+
resolved = resolvePythonImport(imp.source, context.projectPath, allFiles);
|
|
1503
|
+
}
|
|
1504
|
+
else {
|
|
1505
|
+
// Check if it's a path alias (e.g., @/services/timeEntries, ~/utils)
|
|
1506
|
+
resolved = resolvePathAlias(imp.source, pathAliases, allFiles);
|
|
1507
|
+
}
|
|
1508
|
+
if (resolved) {
|
|
1509
|
+
imports.push(resolved);
|
|
1510
|
+
// Add dependency edge
|
|
1511
|
+
context.dependencies.push({
|
|
1512
|
+
from: filePath,
|
|
1513
|
+
to: resolved,
|
|
1514
|
+
importedSymbols: [...imp.namedImports, imp.defaultImport].filter(Boolean),
|
|
1515
|
+
});
|
|
1516
|
+
// Update reverse graph
|
|
1517
|
+
if (!context.reverseImportGraph.has(resolved)) {
|
|
1518
|
+
context.reverseImportGraph.set(resolved, []);
|
|
1519
|
+
}
|
|
1520
|
+
context.reverseImportGraph.get(resolved).push(filePath);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
context.importGraph.set(filePath, imports);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
/**
|
|
1527
|
+
* Detect path aliases from tsconfig.json or common patterns
|
|
1528
|
+
*/
|
|
1529
|
+
async function detectPathAliasesAsync(projectPath, allFiles) {
|
|
1530
|
+
const aliases = new Map();
|
|
1531
|
+
// Try to read tsconfig.json for actual path mappings
|
|
1532
|
+
try {
|
|
1533
|
+
const tsconfigPath = path.join(projectPath, "tsconfig.json");
|
|
1534
|
+
const tsconfigContent = await fs.readFile(tsconfigPath, "utf-8");
|
|
1535
|
+
const tsconfig = JSON.parse(tsconfigContent);
|
|
1536
|
+
if (tsconfig.compilerOptions?.paths) {
|
|
1537
|
+
const baseUrl = tsconfig.compilerOptions.baseUrl || ".";
|
|
1538
|
+
const basePath = path.join(projectPath, baseUrl);
|
|
1539
|
+
for (const [alias, targets] of Object.entries(tsconfig.compilerOptions.paths)) {
|
|
1540
|
+
if (Array.isArray(targets) && targets.length > 0) {
|
|
1541
|
+
// Remove trailing /* from alias pattern
|
|
1542
|
+
const cleanAlias = alias.replace(/\/\*$/, "/");
|
|
1543
|
+
// Remove trailing /* from target and resolve path
|
|
1544
|
+
const target = targets[0].replace(/\/\*$/, "");
|
|
1545
|
+
const resolvedTarget = path.join(basePath, target);
|
|
1546
|
+
aliases.set(cleanAlias, resolvedTarget);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
catch {
|
|
1552
|
+
// tsconfig.json not found or invalid, use heuristics
|
|
1553
|
+
}
|
|
1554
|
+
// If no aliases found from tsconfig, use common patterns
|
|
1555
|
+
if (aliases.size === 0) {
|
|
1556
|
+
const commonAliases = [
|
|
1557
|
+
{ prefix: "@/", dirs: ["src", "app", "lib", "."] },
|
|
1558
|
+
{ prefix: "~/", dirs: ["src", "app", "lib", "."] },
|
|
1559
|
+
{ prefix: "@", dirs: ["src", "app", "lib"] },
|
|
1560
|
+
];
|
|
1561
|
+
for (const alias of commonAliases) {
|
|
1562
|
+
for (const dir of alias.dirs) {
|
|
1563
|
+
const testPath = path.join(projectPath, dir);
|
|
1564
|
+
const hasFiles = allFiles.some((f) => f.startsWith(testPath + path.sep));
|
|
1565
|
+
if (hasFiles) {
|
|
1566
|
+
aliases.set(alias.prefix, testPath);
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return aliases;
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* Synchronous version for use in buildDependencyGraph
|
|
1576
|
+
*/
|
|
1577
|
+
function detectPathAliases(projectPath, allFiles) {
|
|
1578
|
+
const aliases = new Map();
|
|
1579
|
+
// Common alias patterns and their typical mappings
|
|
1580
|
+
const commonAliases = [
|
|
1581
|
+
{ prefix: "@/", dirs: ["src", "app", "lib", "."] },
|
|
1582
|
+
{ prefix: "~/", dirs: ["src", "app", "lib", "."] },
|
|
1583
|
+
{ prefix: "@", dirs: ["src", "app", "lib"] },
|
|
1584
|
+
];
|
|
1585
|
+
for (const alias of commonAliases) {
|
|
1586
|
+
for (const dir of alias.dirs) {
|
|
1587
|
+
const testPath = path.join(projectPath, dir);
|
|
1588
|
+
const hasFiles = allFiles.some((f) => f.startsWith(testPath + path.sep));
|
|
1589
|
+
if (hasFiles) {
|
|
1590
|
+
aliases.set(alias.prefix, testPath);
|
|
1591
|
+
break;
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
return aliases;
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Resolve a path alias import to an actual file
|
|
1599
|
+
*/
|
|
1600
|
+
function resolvePathAlias(importPath, aliases, allFiles) {
|
|
1601
|
+
// Try each alias prefix
|
|
1602
|
+
for (const [prefix, basePath] of aliases) {
|
|
1603
|
+
if (importPath.startsWith(prefix)) {
|
|
1604
|
+
const relativePart = importPath.slice(prefix.length);
|
|
1605
|
+
const resolved = path.join(basePath, relativePart);
|
|
1606
|
+
// Try exact match
|
|
1607
|
+
if (allFiles.includes(resolved))
|
|
1608
|
+
return resolved;
|
|
1609
|
+
// Try with extensions
|
|
1610
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
|
|
1611
|
+
const withExt = resolved + ext;
|
|
1612
|
+
if (allFiles.includes(withExt))
|
|
1613
|
+
return withExt;
|
|
1614
|
+
}
|
|
1615
|
+
// Try index files
|
|
1616
|
+
for (const indexFile of ["index.ts", "index.tsx", "index.js"]) {
|
|
1617
|
+
const withIndex = path.join(resolved, indexFile);
|
|
1618
|
+
if (allFiles.includes(withIndex))
|
|
1619
|
+
return withIndex;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
return null;
|
|
1624
|
+
}
|
|
1625
|
+
export function resolveImport(importPath, fromFile, allFiles) {
|
|
1626
|
+
const dir = path.dirname(fromFile);
|
|
1627
|
+
const resolved = path.normalize(path.join(dir, importPath));
|
|
1628
|
+
// Try exact match
|
|
1629
|
+
if (allFiles.includes(resolved))
|
|
1630
|
+
return resolved;
|
|
1631
|
+
// Handle .js -> .ts extension mapping (common in TypeScript projects with ESM)
|
|
1632
|
+
// e.g., import from './foo.js' should resolve to './foo.ts'
|
|
1633
|
+
if (resolved.endsWith(".js")) {
|
|
1634
|
+
const withTs = resolved.slice(0, -3) + ".ts";
|
|
1635
|
+
if (allFiles.includes(withTs))
|
|
1636
|
+
return withTs;
|
|
1637
|
+
const withTsx = resolved.slice(0, -3) + ".tsx";
|
|
1638
|
+
if (allFiles.includes(withTsx))
|
|
1639
|
+
return withTsx;
|
|
1640
|
+
}
|
|
1641
|
+
if (resolved.endsWith(".jsx")) {
|
|
1642
|
+
const withTsx = resolved.slice(0, -4) + ".tsx";
|
|
1643
|
+
if (allFiles.includes(withTsx))
|
|
1644
|
+
return withTsx;
|
|
1645
|
+
const withTs = resolved.slice(0, -4) + ".ts";
|
|
1646
|
+
if (allFiles.includes(withTs))
|
|
1647
|
+
return withTs;
|
|
1648
|
+
}
|
|
1649
|
+
// Try with extensions
|
|
1650
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx", ".py"]) {
|
|
1651
|
+
const withExt = resolved + ext;
|
|
1652
|
+
if (allFiles.includes(withExt))
|
|
1653
|
+
return withExt;
|
|
1654
|
+
}
|
|
1655
|
+
// Try index files
|
|
1656
|
+
for (const indexFile of [
|
|
1657
|
+
"index.ts",
|
|
1658
|
+
"index.tsx",
|
|
1659
|
+
"index.js",
|
|
1660
|
+
"__init__.py",
|
|
1661
|
+
]) {
|
|
1662
|
+
const withIndex = path.join(resolved, indexFile);
|
|
1663
|
+
if (allFiles.includes(withIndex))
|
|
1664
|
+
return withIndex;
|
|
1665
|
+
}
|
|
1666
|
+
return null;
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Resolve a Python dotted module path to an actual file path.
|
|
1670
|
+
* e.g., "app.core.config" → "/project/app/core/config.py"
|
|
1671
|
+
* "app.db" → "/project/app/db/__init__.py"
|
|
1672
|
+
*/
|
|
1673
|
+
function resolvePythonImport(modulePath, projectPath, allFiles) {
|
|
1674
|
+
// Convert dotted path to filesystem path: app.core.config → app/core/config
|
|
1675
|
+
const parts = modulePath.split(".");
|
|
1676
|
+
const relPath = parts.join("/");
|
|
1677
|
+
// Try as .py file relative to project root
|
|
1678
|
+
const asPy = path.join(projectPath, relPath + ".py");
|
|
1679
|
+
if (allFiles.includes(asPy))
|
|
1680
|
+
return asPy;
|
|
1681
|
+
// Try as package directory with __init__.py
|
|
1682
|
+
const asInit = path.join(projectPath, relPath, "__init__.py");
|
|
1683
|
+
if (allFiles.includes(asInit))
|
|
1684
|
+
return asInit;
|
|
1685
|
+
// Try looking under common subdirs (backend/, src/, etc.)
|
|
1686
|
+
for (const subdir of ["backend", "src", ""]) {
|
|
1687
|
+
if (!subdir)
|
|
1688
|
+
continue;
|
|
1689
|
+
const withSubdir = path.join(projectPath, subdir, relPath + ".py");
|
|
1690
|
+
if (allFiles.includes(withSubdir))
|
|
1691
|
+
return withSubdir;
|
|
1692
|
+
const withSubdirInit = path.join(projectPath, subdir, relPath, "__init__.py");
|
|
1693
|
+
if (allFiles.includes(withSubdirInit))
|
|
1694
|
+
return withSubdirInit;
|
|
1695
|
+
}
|
|
1696
|
+
return null;
|
|
1697
|
+
}
|
|
1698
|
+
// ============================================================================
|
|
1699
|
+
// Helpers
|
|
1700
|
+
// ============================================================================
|
|
1701
|
+
function getLanguageFromExt(ext) {
|
|
1702
|
+
const map = {
|
|
1703
|
+
".js": "javascript",
|
|
1704
|
+
".jsx": "javascript",
|
|
1705
|
+
".mjs": "javascript",
|
|
1706
|
+
".ts": "typescript",
|
|
1707
|
+
".tsx": "typescript",
|
|
1708
|
+
".mts": "typescript",
|
|
1709
|
+
".py": "python",
|
|
1710
|
+
".go": "go",
|
|
1711
|
+
".java": "java",
|
|
1712
|
+
};
|
|
1713
|
+
return map[ext] || "unknown";
|
|
1714
|
+
}
|
|
1715
|
+
function parseParams(paramStr) {
|
|
1716
|
+
if (!paramStr.trim())
|
|
1717
|
+
return [];
|
|
1718
|
+
return paramStr.split(",").map((p) => {
|
|
1719
|
+
const parts = p.trim().split(":");
|
|
1720
|
+
return {
|
|
1721
|
+
name: parts[0].replace(/[?]$/, "").trim(),
|
|
1722
|
+
type: parts[1]?.trim(),
|
|
1723
|
+
};
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
function isTestFile(filePath) {
|
|
1727
|
+
const lower = filePath.toLowerCase();
|
|
1728
|
+
return (lower.includes(".test.") ||
|
|
1729
|
+
lower.includes(".spec.") ||
|
|
1730
|
+
lower.includes("__tests__") ||
|
|
1731
|
+
lower.includes("/test/") ||
|
|
1732
|
+
lower.includes("/tests/"));
|
|
1733
|
+
}
|
|
1734
|
+
function isConfigFile(fileName) {
|
|
1735
|
+
const configPatterns = [
|
|
1736
|
+
/^\..*rc$/,
|
|
1737
|
+
/config\./,
|
|
1738
|
+
/\.config\./,
|
|
1739
|
+
/settings\./,
|
|
1740
|
+
/\.env/,
|
|
1741
|
+
/package\.json/,
|
|
1742
|
+
/tsconfig/,
|
|
1743
|
+
/jest\.config/,
|
|
1744
|
+
/webpack\.config/,
|
|
1745
|
+
/vite\.config/,
|
|
1746
|
+
/next\.config/,
|
|
1747
|
+
];
|
|
1748
|
+
return configPatterns.some((p) => p.test(fileName.toLowerCase()));
|
|
1749
|
+
}
|
|
1750
|
+
function isEntryPointFile(filePath, frameworks) {
|
|
1751
|
+
const fileName = path.basename(filePath).toLowerCase();
|
|
1752
|
+
const relativePath = filePath.toLowerCase();
|
|
1753
|
+
// Common entry points
|
|
1754
|
+
const commonEntryNames = ["index", "main", "app", "server", "cli", "tool", "handler", "mcp", "worker"];
|
|
1755
|
+
if (commonEntryNames.some((n) => fileName.startsWith(n))) {
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
// Root files in src/ are often entry points or public APIs
|
|
1759
|
+
// For example: src/validateCode.ts
|
|
1760
|
+
const parts = relativePath.split(/[/\\]/);
|
|
1761
|
+
if (parts.length === 2 && parts[0] === "src") {
|
|
1762
|
+
return true;
|
|
1763
|
+
}
|
|
1764
|
+
// Bin directory
|
|
1765
|
+
if (relativePath.includes("/bin/")) {
|
|
1766
|
+
return true;
|
|
1767
|
+
}
|
|
1768
|
+
// Framework-specific — check ALL detected frameworks
|
|
1769
|
+
if (frameworks) {
|
|
1770
|
+
for (const framework of frameworks) {
|
|
1771
|
+
if (framework.name === "nextjs") {
|
|
1772
|
+
if (relativePath.includes("/app/") &&
|
|
1773
|
+
(fileName === "page.tsx" ||
|
|
1774
|
+
fileName === "page.ts" ||
|
|
1775
|
+
fileName === "layout.tsx")) {
|
|
1776
|
+
return true;
|
|
1777
|
+
}
|
|
1778
|
+
if (relativePath.includes("/pages/") && !fileName.startsWith("_")) {
|
|
1779
|
+
return true;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
if (framework.name === "fastapi") {
|
|
1783
|
+
if (fileName === "main.py" || fileName === "app.py") {
|
|
1784
|
+
return true;
|
|
1785
|
+
}
|
|
1786
|
+
if (relativePath.includes("/api/") && fileName.endsWith(".py")) {
|
|
1787
|
+
return true;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return false;
|
|
1793
|
+
}
|
|
1794
|
+
async function detectFrameworks(projectPath, files) {
|
|
1795
|
+
const frameworks = [];
|
|
1796
|
+
// Check for Next.js
|
|
1797
|
+
const hasNextConfig = files.some((f) => f.includes("next.config"));
|
|
1798
|
+
const hasAppDir = files.some((f) => f.includes("/app/page."));
|
|
1799
|
+
const hasPagesDir = files.some((f) => f.includes("/pages/"));
|
|
1800
|
+
if (hasNextConfig || hasAppDir || hasPagesDir) {
|
|
1801
|
+
frameworks.push({
|
|
1802
|
+
name: "nextjs",
|
|
1803
|
+
patterns: ["app/", "pages/", "components/", "lib/"],
|
|
1804
|
+
});
|
|
1805
|
+
}
|
|
1806
|
+
// Check for React (without Next)
|
|
1807
|
+
if (frameworks.length === 0) {
|
|
1808
|
+
const hasReact = files.some((f) => f.includes("App.tsx") || f.includes("App.jsx"));
|
|
1809
|
+
if (hasReact) {
|
|
1810
|
+
frameworks.push({
|
|
1811
|
+
name: "react",
|
|
1812
|
+
patterns: ["src/", "components/"],
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
// Check for FastAPI/Flask (always check — not mutually exclusive with frontend frameworks)
|
|
1817
|
+
const hasFastAPI = files.some((f) => {
|
|
1818
|
+
const name = path.basename(f);
|
|
1819
|
+
return name === "main.py" || name === "app.py";
|
|
1820
|
+
});
|
|
1821
|
+
if (hasFastAPI) {
|
|
1822
|
+
frameworks.push({
|
|
1823
|
+
name: "fastapi",
|
|
1824
|
+
patterns: ["app/", "api/", "routers/", "services/"],
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
return frameworks;
|
|
1828
|
+
}
|
|
1829
|
+
function extractKeywords(filePath, content) {
|
|
1830
|
+
const keywords = [];
|
|
1831
|
+
// From path
|
|
1832
|
+
const pathParts = filePath
|
|
1833
|
+
.split(/[/\\]/)
|
|
1834
|
+
.filter((p) => p && !p.startsWith("."));
|
|
1835
|
+
keywords.push(...pathParts.map((p) => p.toLowerCase().replace(/\.[^.]+$/, "")));
|
|
1836
|
+
// From content (top words by frequency)
|
|
1837
|
+
const words = content.match(/\b[a-zA-Z][a-zA-Z0-9_]{2,}\b/g) || [];
|
|
1838
|
+
const wordFreq = new Map();
|
|
1839
|
+
const stopWords = new Set([
|
|
1840
|
+
"the",
|
|
1841
|
+
"and",
|
|
1842
|
+
"for",
|
|
1843
|
+
"from",
|
|
1844
|
+
"import",
|
|
1845
|
+
"export",
|
|
1846
|
+
"const",
|
|
1847
|
+
"let",
|
|
1848
|
+
"var",
|
|
1849
|
+
"function",
|
|
1850
|
+
"return",
|
|
1851
|
+
"this",
|
|
1852
|
+
"that",
|
|
1853
|
+
"with",
|
|
1854
|
+
"async",
|
|
1855
|
+
"await",
|
|
1856
|
+
"true",
|
|
1857
|
+
"false",
|
|
1858
|
+
]);
|
|
1859
|
+
for (const word of words) {
|
|
1860
|
+
const lower = word.toLowerCase();
|
|
1861
|
+
if (!stopWords.has(lower) && lower.length > 3) {
|
|
1862
|
+
wordFreq.set(lower, (wordFreq.get(lower) || 0) + 1);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
const topKeywords = Array.from(wordFreq.entries())
|
|
1866
|
+
.sort((a, b) => b[1] - a[1])
|
|
1867
|
+
.slice(0, 15)
|
|
1868
|
+
.map(([word]) => word);
|
|
1869
|
+
keywords.push(...topKeywords);
|
|
1870
|
+
return [...new Set(keywords)];
|
|
1871
|
+
}
|
|
1872
|
+
// ============================================================================
|
|
1873
|
+
// Disk Persistence
|
|
1874
|
+
// ============================================================================
|
|
1875
|
+
/**
|
|
1876
|
+
* Load context from disk cache
|
|
1877
|
+
*/
|
|
1878
|
+
async function loadContextFromDisk(projectPath, currentGitInfo) {
|
|
1879
|
+
try {
|
|
1880
|
+
const cacheDir = path.join(projectPath, CACHE_DIR_NAME);
|
|
1881
|
+
const cacheFile = path.join(cacheDir, CACHE_FILE_NAME);
|
|
1882
|
+
// Check if file exists
|
|
1883
|
+
try {
|
|
1884
|
+
await fs.access(cacheFile);
|
|
1885
|
+
}
|
|
1886
|
+
catch {
|
|
1887
|
+
return null;
|
|
1888
|
+
}
|
|
1889
|
+
const content = await fs.readFile(cacheFile, "utf-8");
|
|
1890
|
+
const cached = deserialize(content);
|
|
1891
|
+
// Backward compat: migrate old "framework" (singular) to "frameworks" (array)
|
|
1892
|
+
const ctx = cached.context;
|
|
1893
|
+
if (!ctx.frameworks) {
|
|
1894
|
+
ctx.frameworks = ctx.framework ? [ctx.framework] : [];
|
|
1895
|
+
delete ctx.framework;
|
|
1896
|
+
}
|
|
1897
|
+
// Verify it belongs to this project path (just in case)
|
|
1898
|
+
if (cached.context.projectPath !== projectPath) {
|
|
1899
|
+
logger.info(`Disk cache path mismatch: ${cached.context.projectPath} vs ${projectPath}`);
|
|
1900
|
+
return null;
|
|
1901
|
+
}
|
|
1902
|
+
// Strict Git Check: If git info doesn't match exactly, discard cache
|
|
1903
|
+
if (currentGitInfo && cached.gitInfo) {
|
|
1904
|
+
if (currentGitInfo.branch !== cached.gitInfo.branch ||
|
|
1905
|
+
currentGitInfo.commitSHA !== cached.gitInfo.commitSHA) {
|
|
1906
|
+
logger.info(`Disk cache invalid: Git commit/branch mismatch (${currentGitInfo.commitSHA} vs ${cached.gitInfo.commitSHA})`);
|
|
1907
|
+
return null;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
else if (currentGitInfo || cached.gitInfo) {
|
|
1911
|
+
// One has git, the other doesn't -> mismatch
|
|
1912
|
+
logger.info("Disk cache invalid: Git presence mismatch");
|
|
1913
|
+
return null;
|
|
1914
|
+
}
|
|
1915
|
+
// If no git, check file count as basic proxy
|
|
1916
|
+
// (This is less reliable but better than nothing for non-git projects)
|
|
1917
|
+
if (!currentGitInfo) {
|
|
1918
|
+
// Simple age check - expire after 1 hour if no git
|
|
1919
|
+
if (Date.now() - cached.timestamp > 60 * 60 * 1000) {
|
|
1920
|
+
logger.info("Disk cache expired (no git)");
|
|
1921
|
+
return null;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
logger.info(`Hydrated context from disk for ${projectPath}`);
|
|
1925
|
+
return cached;
|
|
1926
|
+
}
|
|
1927
|
+
catch (error) {
|
|
1928
|
+
logger.warn(`Failed to load context from disk: ${error instanceof Error ? error.message : String(error)}`);
|
|
1929
|
+
return null;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Debounced wrapper for saveContextToDisk.
|
|
1934
|
+
* During rapid vibecoding, dozens of refreshFileContext calls fire within seconds.
|
|
1935
|
+
* Each one previously called saveContextToDisk immediately, causing concurrent writes
|
|
1936
|
+
* to the same file. This debouncer coalesces them into a single write after a 2-second
|
|
1937
|
+
* quiet period.
|
|
1938
|
+
*/
|
|
1939
|
+
const pendingSaveTimers = new Map();
|
|
1940
|
+
function debouncedSaveContextToDisk(projectPath, cachedContext) {
|
|
1941
|
+
const existing = pendingSaveTimers.get(projectPath);
|
|
1942
|
+
if (existing) {
|
|
1943
|
+
clearTimeout(existing);
|
|
1944
|
+
}
|
|
1945
|
+
const timer = setTimeout(() => {
|
|
1946
|
+
pendingSaveTimers.delete(projectPath);
|
|
1947
|
+
saveContextToDisk(projectPath, cachedContext).catch(err => {
|
|
1948
|
+
logger.warn(`Failed to save refreshed context to disk: ${err instanceof Error ? err.message : String(err)}`);
|
|
1949
|
+
});
|
|
1950
|
+
}, 2_000);
|
|
1951
|
+
pendingSaveTimers.set(projectPath, timer);
|
|
1952
|
+
}
|
|
1953
|
+
/**
|
|
1954
|
+
* Save context to disk cache
|
|
1955
|
+
*/
|
|
1956
|
+
async function saveContextToDisk(projectPath, cachedContext) {
|
|
1957
|
+
try {
|
|
1958
|
+
const cacheDir = path.join(projectPath, CACHE_DIR_NAME);
|
|
1959
|
+
// Ensure .gitignore exists and includes the cache directory
|
|
1960
|
+
const gitignorePath = path.join(projectPath, ".gitignore");
|
|
1961
|
+
try {
|
|
1962
|
+
const gitignore = await fs.readFile(gitignorePath, "utf-8");
|
|
1963
|
+
if (!gitignore.includes(CACHE_DIR_NAME)) {
|
|
1964
|
+
await fs.appendFile(gitignorePath, `\n# CodeGuardian Cache\n${CACHE_DIR_NAME}/\n`);
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
catch {
|
|
1968
|
+
// No .gitignore, create one
|
|
1969
|
+
await fs.writeFile(gitignorePath, `# CodeGuardian Cache\n${CACHE_DIR_NAME}/\n`);
|
|
1970
|
+
}
|
|
1971
|
+
// Create cache directory
|
|
1972
|
+
await fs.mkdir(cacheDir, { recursive: true });
|
|
1973
|
+
const cacheFile = path.join(cacheDir, CACHE_FILE_NAME);
|
|
1974
|
+
const tempFile = `${cacheFile}.tmp`;
|
|
1975
|
+
const content = serialize(cachedContext);
|
|
1976
|
+
await fs.writeFile(tempFile, content, "utf-8");
|
|
1977
|
+
await fs.rename(tempFile, cacheFile);
|
|
1978
|
+
logger.debug(`Persisted context to ${cacheFile}`);
|
|
1979
|
+
}
|
|
1980
|
+
catch (error) {
|
|
1981
|
+
logger.warn(`Failed to save context to disk: ${error instanceof Error ? error.message : String(error)}`);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
//# sourceMappingURL=projectContext.js.map
|