This is page 20 of 29. Use http://codebase.md/tosin2013/documcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .dockerignore
├── .eslintignore
├── .eslintrc.json
├── .github
│ ├── agents
│ │ ├── documcp-ast.md
│ │ ├── documcp-deploy.md
│ │ ├── documcp-memory.md
│ │ ├── documcp-test.md
│ │ └── documcp-tool.md
│ ├── copilot-instructions.md
│ ├── dependabot.yml
│ ├── ISSUE_TEMPLATE
│ │ ├── automated-changelog.md
│ │ ├── bug_report.md
│ │ ├── bug_report.yml
│ │ ├── documentation_issue.md
│ │ ├── feature_request.md
│ │ ├── feature_request.yml
│ │ ├── npm-publishing-fix.md
│ │ └── release_improvements.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── release-drafter.yml
│ └── workflows
│ ├── auto-merge.yml
│ ├── ci.yml
│ ├── codeql.yml
│ ├── dependency-review.yml
│ ├── deploy-docs.yml
│ ├── README.md
│ ├── release-drafter.yml
│ └── release.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .linkcheck.config.json
├── .markdown-link-check.json
├── .nvmrc
├── .pre-commit-config.yaml
├── .versionrc.json
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── commitlint.config.js
├── CONTRIBUTING.md
├── docker-compose.docs.yml
├── Dockerfile.docs
├── docs
│ ├── .docusaurus
│ │ ├── docusaurus-plugin-content-docs
│ │ │ └── default
│ │ │ └── __mdx-loader-dependency.json
│ │ └── docusaurus-plugin-content-pages
│ │ └── default
│ │ └── __plugin.json
│ ├── adrs
│ │ ├── 001-mcp-server-architecture.md
│ │ ├── 002-repository-analysis-engine.md
│ │ ├── 003-static-site-generator-recommendation-engine.md
│ │ ├── 004-diataxis-framework-integration.md
│ │ ├── 005-github-pages-deployment-automation.md
│ │ ├── 006-mcp-tools-api-design.md
│ │ ├── 007-mcp-prompts-and-resources-integration.md
│ │ ├── 008-intelligent-content-population-engine.md
│ │ ├── 009-content-accuracy-validation-framework.md
│ │ ├── 010-mcp-resource-pattern-redesign.md
│ │ └── README.md
│ ├── api
│ │ ├── .nojekyll
│ │ ├── assets
│ │ │ ├── hierarchy.js
│ │ │ ├── highlight.css
│ │ │ ├── icons.js
│ │ │ ├── icons.svg
│ │ │ ├── main.js
│ │ │ ├── navigation.js
│ │ │ ├── search.js
│ │ │ └── style.css
│ │ ├── hierarchy.html
│ │ ├── index.html
│ │ ├── modules.html
│ │ └── variables
│ │ └── TOOLS.html
│ ├── assets
│ │ └── logo.svg
│ ├── development
│ │ └── MCP_INSPECTOR_TESTING.md
│ ├── docusaurus.config.js
│ ├── explanation
│ │ ├── architecture.md
│ │ └── index.md
│ ├── guides
│ │ ├── link-validation.md
│ │ ├── playwright-integration.md
│ │ └── playwright-testing-workflow.md
│ ├── how-to
│ │ ├── analytics-setup.md
│ │ ├── custom-domains.md
│ │ ├── documentation-freshness-tracking.md
│ │ ├── github-pages-deployment.md
│ │ ├── index.md
│ │ ├── local-testing.md
│ │ ├── performance-optimization.md
│ │ ├── prompting-guide.md
│ │ ├── repository-analysis.md
│ │ ├── seo-optimization.md
│ │ ├── site-monitoring.md
│ │ ├── troubleshooting.md
│ │ └── usage-examples.md
│ ├── index.md
│ ├── knowledge-graph.md
│ ├── package-lock.json
│ ├── package.json
│ ├── phase-2-intelligence.md
│ ├── reference
│ │ ├── api-overview.md
│ │ ├── cli.md
│ │ ├── configuration.md
│ │ ├── deploy-pages.md
│ │ ├── index.md
│ │ ├── mcp-tools.md
│ │ └── prompt-templates.md
│ ├── research
│ │ ├── cross-domain-integration
│ │ │ └── README.md
│ │ ├── domain-1-mcp-architecture
│ │ │ ├── index.md
│ │ │ └── mcp-performance-research.md
│ │ ├── domain-2-repository-analysis
│ │ │ └── README.md
│ │ ├── domain-3-ssg-recommendation
│ │ │ ├── index.md
│ │ │ └── ssg-performance-analysis.md
│ │ ├── domain-4-diataxis-integration
│ │ │ └── README.md
│ │ ├── domain-5-github-deployment
│ │ │ ├── github-pages-security-analysis.md
│ │ │ └── index.md
│ │ ├── domain-6-api-design
│ │ │ └── README.md
│ │ ├── README.md
│ │ ├── research-integration-summary-2025-01-14.md
│ │ ├── research-progress-template.md
│ │ └── research-questions-2025-01-14.md
│ ├── robots.txt
│ ├── sidebars.js
│ ├── sitemap.xml
│ ├── src
│ │ └── css
│ │ └── custom.css
│ └── tutorials
│ ├── development-setup.md
│ ├── environment-setup.md
│ ├── first-deployment.md
│ ├── getting-started.md
│ ├── index.md
│ ├── memory-workflows.md
│ └── user-onboarding.md
├── jest.config.js
├── LICENSE
├── Makefile
├── MCP_PHASE2_IMPLEMENTATION.md
├── mcp-config-example.json
├── mcp.json
├── package-lock.json
├── package.json
├── README.md
├── release.sh
├── scripts
│ └── check-package-structure.cjs
├── SECURITY.md
├── setup-precommit.sh
├── src
│ ├── benchmarks
│ │ └── performance.ts
│ ├── index.ts
│ ├── memory
│ │ ├── contextual-retrieval.ts
│ │ ├── deployment-analytics.ts
│ │ ├── enhanced-manager.ts
│ │ ├── export-import.ts
│ │ ├── freshness-kg-integration.ts
│ │ ├── index.ts
│ │ ├── integration.ts
│ │ ├── kg-code-integration.ts
│ │ ├── kg-health.ts
│ │ ├── kg-integration.ts
│ │ ├── kg-link-validator.ts
│ │ ├── kg-storage.ts
│ │ ├── knowledge-graph.ts
│ │ ├── learning.ts
│ │ ├── manager.ts
│ │ ├── multi-agent-sharing.ts
│ │ ├── pruning.ts
│ │ ├── schemas.ts
│ │ ├── storage.ts
│ │ ├── temporal-analysis.ts
│ │ ├── user-preferences.ts
│ │ └── visualization.ts
│ ├── prompts
│ │ └── technical-writer-prompts.ts
│ ├── scripts
│ │ └── benchmark.ts
│ ├── templates
│ │ └── playwright
│ │ ├── accessibility.spec.template.ts
│ │ ├── Dockerfile.template
│ │ ├── docs-e2e.workflow.template.yml
│ │ ├── link-validation.spec.template.ts
│ │ └── playwright.config.template.ts
│ ├── tools
│ │ ├── analyze-deployments.ts
│ │ ├── analyze-readme.ts
│ │ ├── analyze-repository.ts
│ │ ├── check-documentation-links.ts
│ │ ├── deploy-pages.ts
│ │ ├── detect-gaps.ts
│ │ ├── evaluate-readme-health.ts
│ │ ├── generate-config.ts
│ │ ├── generate-contextual-content.ts
│ │ ├── generate-llm-context.ts
│ │ ├── generate-readme-template.ts
│ │ ├── generate-technical-writer-prompts.ts
│ │ ├── kg-health-check.ts
│ │ ├── manage-preferences.ts
│ │ ├── manage-sitemap.ts
│ │ ├── optimize-readme.ts
│ │ ├── populate-content.ts
│ │ ├── readme-best-practices.ts
│ │ ├── recommend-ssg.ts
│ │ ├── setup-playwright-tests.ts
│ │ ├── setup-structure.ts
│ │ ├── sync-code-to-docs.ts
│ │ ├── test-local-deployment.ts
│ │ ├── track-documentation-freshness.ts
│ │ ├── update-existing-documentation.ts
│ │ ├── validate-content.ts
│ │ ├── validate-documentation-freshness.ts
│ │ ├── validate-readme-checklist.ts
│ │ └── verify-deployment.ts
│ ├── types
│ │ └── api.ts
│ ├── utils
│ │ ├── ast-analyzer.ts
│ │ ├── code-scanner.ts
│ │ ├── content-extractor.ts
│ │ ├── drift-detector.ts
│ │ ├── freshness-tracker.ts
│ │ ├── language-parsers-simple.ts
│ │ ├── permission-checker.ts
│ │ └── sitemap-generator.ts
│ └── workflows
│ └── documentation-workflow.ts
├── test-docs-local.sh
├── tests
│ ├── api
│ │ └── mcp-responses.test.ts
│ ├── benchmarks
│ │ └── performance.test.ts
│ ├── edge-cases
│ │ └── error-handling.test.ts
│ ├── functional
│ │ └── tools.test.ts
│ ├── integration
│ │ ├── kg-documentation-workflow.test.ts
│ │ ├── knowledge-graph-workflow.test.ts
│ │ ├── mcp-readme-tools.test.ts
│ │ ├── memory-mcp-tools.test.ts
│ │ ├── readme-technical-writer.test.ts
│ │ └── workflow.test.ts
│ ├── memory
│ │ ├── contextual-retrieval.test.ts
│ │ ├── enhanced-manager.test.ts
│ │ ├── export-import.test.ts
│ │ ├── freshness-kg-integration.test.ts
│ │ ├── kg-code-integration.test.ts
│ │ ├── kg-health.test.ts
│ │ ├── kg-link-validator.test.ts
│ │ ├── kg-storage-validation.test.ts
│ │ ├── kg-storage.test.ts
│ │ ├── knowledge-graph-enhanced.test.ts
│ │ ├── knowledge-graph.test.ts
│ │ ├── learning.test.ts
│ │ ├── manager-advanced.test.ts
│ │ ├── manager.test.ts
│ │ ├── mcp-resource-integration.test.ts
│ │ ├── mcp-tool-persistence.test.ts
│ │ ├── schemas.test.ts
│ │ ├── storage.test.ts
│ │ ├── temporal-analysis.test.ts
│ │ └── user-preferences.test.ts
│ ├── performance
│ │ ├── memory-load-testing.test.ts
│ │ └── memory-stress-testing.test.ts
│ ├── prompts
│ │ ├── guided-workflow-prompts.test.ts
│ │ └── technical-writer-prompts.test.ts
│ ├── server.test.ts
│ ├── setup.ts
│ ├── tools
│ │ ├── all-tools.test.ts
│ │ ├── analyze-coverage.test.ts
│ │ ├── analyze-deployments.test.ts
│ │ ├── analyze-readme.test.ts
│ │ ├── analyze-repository.test.ts
│ │ ├── check-documentation-links.test.ts
│ │ ├── deploy-pages-kg-retrieval.test.ts
│ │ ├── deploy-pages-tracking.test.ts
│ │ ├── deploy-pages.test.ts
│ │ ├── detect-gaps.test.ts
│ │ ├── evaluate-readme-health.test.ts
│ │ ├── generate-contextual-content.test.ts
│ │ ├── generate-llm-context.test.ts
│ │ ├── generate-readme-template.test.ts
│ │ ├── generate-technical-writer-prompts.test.ts
│ │ ├── kg-health-check.test.ts
│ │ ├── manage-sitemap.test.ts
│ │ ├── optimize-readme.test.ts
│ │ ├── readme-best-practices.test.ts
│ │ ├── recommend-ssg-historical.test.ts
│ │ ├── recommend-ssg-preferences.test.ts
│ │ ├── recommend-ssg.test.ts
│ │ ├── simple-coverage.test.ts
│ │ ├── sync-code-to-docs.test.ts
│ │ ├── test-local-deployment.test.ts
│ │ ├── tool-error-handling.test.ts
│ │ ├── track-documentation-freshness.test.ts
│ │ ├── validate-content.test.ts
│ │ ├── validate-documentation-freshness.test.ts
│ │ └── validate-readme-checklist.test.ts
│ ├── types
│ │ └── type-safety.test.ts
│ └── utils
│ ├── ast-analyzer.test.ts
│ ├── content-extractor.test.ts
│ ├── drift-detector.test.ts
│ ├── freshness-tracker.test.ts
│ └── sitemap-generator.test.ts
├── tsconfig.json
└── typedoc.json
```
# Files
--------------------------------------------------------------------------------
/src/tools/analyze-repository.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { promises as fs } from "fs";
2 | import path from "path";
3 | import { z } from "zod";
4 | import { MCPToolResponse, formatMCPResponse } from "../types/api.js";
5 | import {
6 | createOrUpdateProject,
7 | getProjectContext,
8 | } from "../memory/kg-integration.js";
9 | import {
10 | extractRepositoryContent,
11 | ExtractedContent,
12 | } from "../utils/content-extractor.js";
13 |
14 | // Analysis result schema based on ADR-002
15 | export interface RepositoryAnalysis {
16 | id: string;
17 | timestamp: string;
18 | path: string;
19 | structure: {
20 | totalFiles: number;
21 | totalDirectories: number;
22 | languages: Record<string, number>;
23 | hasTests: boolean;
24 | hasCI: boolean;
25 | hasDocs: boolean;
26 | };
27 | dependencies: {
28 | ecosystem: "javascript" | "python" | "ruby" | "go" | "unknown";
29 | packages: string[];
30 | devPackages: string[];
31 | };
32 | documentation: {
33 | hasReadme: boolean;
34 | hasContributing: boolean;
35 | hasLicense: boolean;
36 | existingDocs: string[];
37 | estimatedComplexity: "simple" | "moderate" | "complex";
38 | extractedContent?: ExtractedContent;
39 | };
40 | recommendations: {
41 | primaryLanguage: string;
42 | projectType: string;
43 | teamSize: "solo" | "small" | "medium" | "large";
44 | };
45 | }
46 |
47 | const inputSchema = z.object({
48 | path: z.string(),
49 | depth: z.enum(["quick", "standard", "deep"]).optional().default("standard"),
50 | });
51 |
52 | /**
53 | * Analyzes a repository to understand its structure, dependencies, and documentation needs.
54 | *
55 | * This is the core function of DocuMCP that performs multi-layered analysis of a codebase
56 | * to provide intelligent insights for documentation generation and deployment recommendations.
57 | * The analysis includes structural analysis, dependency detection, documentation assessment,
58 | * and generates recommendations for optimal documentation strategies.
59 | *
60 | * @param args - The input arguments for repository analysis
61 | * @param args.path - The file system path to the repository to analyze
62 | * @param args.depth - The analysis depth level: "quick" (basic), "standard" (comprehensive), or "deep" (thorough)
63 | *
64 | * @returns Promise resolving to analysis results with content array and error status
65 | * @returns content - Array containing the analysis results in MCP tool response format
66 | * @returns isError - Boolean flag indicating if the analysis encountered errors
67 | *
68 | * @throws {Error} When the repository path is inaccessible or invalid
69 | * @throws {Error} When permission is denied to read the repository
70 | * @throws {Error} When the repository structure cannot be analyzed
71 | *
72 | * @example
73 | * ```typescript
74 | * // Basic repository analysis
75 | * const result = await analyzeRepository({
76 | * path: "/path/to/my/repository",
77 | * depth: "standard"
78 | * });
79 | *
80 | * // Quick analysis for large repositories
81 | * const quickResult = await analyzeRepository({
82 | * path: "/path/to/large/repo",
83 | * depth: "quick"
84 | * });
85 | * ```
86 | *
87 | * @since 1.0.0
88 | * @version 1.2.0 - Added Knowledge Graph integration and historical context
89 | */
90 | export async function analyzeRepository(
91 | args: unknown,
92 | context?: any,
93 | ): Promise<{ content: any[]; isError?: boolean }> {
94 | const startTime = Date.now();
95 | const { path: repoPath, depth } = inputSchema.parse(args);
96 |
97 | try {
98 | // Report initial progress
99 | if (context?.meta?.progressToken) {
100 | await context.meta.reportProgress?.({
101 | progress: 0,
102 | total: 100,
103 | });
104 | }
105 |
106 | await context?.info?.("🔍 Starting repository analysis...");
107 |
108 | // Verify path exists and is accessible
109 | await context?.info?.(`📂 Verifying access to ${repoPath}...`);
110 | await fs.access(repoPath, fs.constants.R_OK);
111 |
112 | // Try to read the directory to catch permission issues early
113 | try {
114 | await fs.readdir(repoPath);
115 | } catch (error: any) {
116 | if (error.code === "EACCES" || error.code === "EPERM") {
117 | throw new Error(`Permission denied: Cannot read directory ${repoPath}`);
118 | }
119 | throw error;
120 | }
121 |
122 | if (context?.meta?.progressToken) {
123 | await context.meta.reportProgress?.({
124 | progress: 10,
125 | total: 100,
126 | });
127 | }
128 |
129 | // Phase 1.2: Get historical context from Knowledge Graph
130 | await context?.info?.(
131 | "📊 Retrieving historical context from Knowledge Graph...",
132 | );
133 | let projectContext;
134 | try {
135 | projectContext = await getProjectContext(repoPath);
136 | if (projectContext.previousAnalyses > 0) {
137 | await context?.info?.(
138 | `✨ Found ${projectContext.previousAnalyses} previous analysis(es) of this project`,
139 | );
140 | }
141 | } catch (error) {
142 | console.warn("Failed to retrieve project context:", error);
143 | projectContext = {
144 | previousAnalyses: 0,
145 | lastAnalyzed: null,
146 | knownTechnologies: [],
147 | similarProjects: [],
148 | };
149 | }
150 |
151 | if (context?.meta?.progressToken) {
152 | await context.meta.reportProgress?.({
153 | progress: 20,
154 | total: 100,
155 | });
156 | }
157 |
158 | await context?.info?.("🔎 Analyzing repository structure...");
159 | const structure = await analyzeStructure(repoPath, depth);
160 |
161 | if (context?.meta?.progressToken) {
162 | await context.meta.reportProgress?.({
163 | progress: 40,
164 | total: 100,
165 | });
166 | }
167 |
168 | await context?.info?.("📦 Analyzing dependencies...");
169 | const dependencies = await analyzeDependencies(repoPath);
170 |
171 | if (context?.meta?.progressToken) {
172 | await context.meta.reportProgress?.({
173 | progress: 60,
174 | total: 100,
175 | });
176 | }
177 |
178 | await context?.info?.("📝 Analyzing documentation...");
179 | const documentation = await analyzeDocumentation(repoPath);
180 |
181 | if (context?.meta?.progressToken) {
182 | await context.meta.reportProgress?.({
183 | progress: 75,
184 | total: 100,
185 | });
186 | }
187 |
188 | await context?.info?.("💡 Generating recommendations...");
189 | const recommendations = await generateRecommendations(repoPath);
190 |
191 | const analysis: RepositoryAnalysis = {
192 | id: generateAnalysisId(),
193 | timestamp: new Date().toISOString(),
194 | path: repoPath,
195 | structure,
196 | dependencies,
197 | documentation,
198 | recommendations,
199 | };
200 |
201 | // Phase 1.2: Store project in Knowledge Graph
202 | if (context?.meta?.progressToken) {
203 | await context.meta.reportProgress?.({
204 | progress: 85,
205 | total: 100,
206 | });
207 | }
208 |
209 | await context?.info?.("💾 Storing analysis in Knowledge Graph...");
210 | try {
211 | await createOrUpdateProject(analysis);
212 | } catch (error) {
213 | console.warn("Failed to store project in Knowledge Graph:", error);
214 | }
215 |
216 | if (context?.meta?.progressToken) {
217 | await context.meta.reportProgress?.({
218 | progress: 90,
219 | total: 100,
220 | });
221 | }
222 |
223 | // Phase 1.3: Get intelligent analysis enrichment
224 | await context?.info?.("🧠 Enriching analysis with historical insights...");
225 | let intelligentAnalysis;
226 | let documentationHealth;
227 | try {
228 | const { getProjectInsights, getSimilarProjects } = await import(
229 | "../memory/index.js"
230 | );
231 | const { getKnowledgeGraph } = await import("../memory/kg-integration.js");
232 |
233 | const insights = await getProjectInsights(repoPath);
234 | const similar = await getSimilarProjects(analysis, 5);
235 |
236 | // Check documentation health from KG
237 | try {
238 | const kg = await getKnowledgeGraph();
239 | const allEdges = await kg.getAllEdges();
240 |
241 | // Find outdated documentation
242 | const outdatedEdges = allEdges.filter((e) => e.type === "outdated_for");
243 |
244 | // Find documentation coverage
245 | const documentsEdges = allEdges.filter((e) => e.type === "documents");
246 | const totalCodeFiles = allEdges.filter(
247 | (e) => e.type === "depends_on" && e.target.startsWith("code_file:"),
248 | ).length;
249 |
250 | const documentedFiles = new Set(documentsEdges.map((e) => e.source))
251 | .size;
252 |
253 | const coveragePercent =
254 | totalCodeFiles > 0
255 | ? Math.round((documentedFiles / totalCodeFiles) * 100)
256 | : 0;
257 |
258 | documentationHealth = {
259 | outdatedCount: outdatedEdges.length,
260 | coveragePercent,
261 | totalCodeFiles,
262 | documentedFiles,
263 | };
264 | } catch (error) {
265 | console.warn("Failed to calculate documentation health:", error);
266 | }
267 |
268 | intelligentAnalysis = {
269 | insights,
270 | similarProjects: similar.slice(0, 3).map((p: any) => ({
271 | path: p.projectPath,
272 | similarity: Math.round((p.similarity || 0) * 100) + "%",
273 | technologies: p.technologies?.join(", ") || "unknown",
274 | })),
275 | ...(documentationHealth && { documentationHealth }),
276 | recommendations: [
277 | // Documentation health recommendations
278 | ...(documentationHealth && documentationHealth.outdatedCount > 0
279 | ? [
280 | `${documentationHealth.outdatedCount} documentation section(s) may be outdated - code has changed since docs were updated`,
281 | ]
282 | : []),
283 | ...(documentationHealth &&
284 | documentationHealth.coveragePercent < 50 &&
285 | documentationHealth.totalCodeFiles > 0
286 | ? [
287 | `Documentation covers only ${documentationHealth.coveragePercent}% of code files - consider documenting more`,
288 | ]
289 | : []),
290 | // Only suggest creating README if it truly doesn't exist
291 | // Don't suggest improvements yet - that requires deeper analysis
292 | ...(analysis.documentation.hasReadme
293 | ? []
294 | : ["Consider creating a README.md for project documentation"]),
295 | // Only suggest docs structure if no docs folder exists at all
296 | ...(analysis.structure.hasDocs
297 | ? []
298 | : analysis.documentation.existingDocs.length === 0
299 | ? [
300 | "Consider setting up documentation structure using Diataxis framework",
301 | ]
302 | : []),
303 | // Infrastructure recommendations are safe as they're objective
304 | ...(analysis.structure.hasTests
305 | ? []
306 | : ["Consider adding test coverage to improve reliability"]),
307 | ...(analysis.structure.hasCI
308 | ? []
309 | : ["Consider setting up CI/CD pipeline for automation"]),
310 | ],
311 | };
312 | } catch (error) {
313 | console.warn("Failed to get intelligent analysis:", error);
314 | }
315 |
316 | // Enhance response with historical context
317 | const contextInfo: string[] = [];
318 | if (projectContext.previousAnalyses > 0) {
319 | contextInfo.push(
320 | `📊 Previously analyzed ${projectContext.previousAnalyses} time(s)`,
321 | );
322 | if (projectContext.lastAnalyzed) {
323 | const lastDate = new Date(
324 | projectContext.lastAnalyzed,
325 | ).toLocaleDateString();
326 | contextInfo.push(`📅 Last analyzed: ${lastDate}`);
327 | }
328 | }
329 |
330 | if (projectContext.knownTechnologies.length > 0) {
331 | contextInfo.push(
332 | `💡 Known technologies: ${projectContext.knownTechnologies.join(", ")}`,
333 | );
334 | }
335 |
336 | if (projectContext.similarProjects.length > 0) {
337 | contextInfo.push(
338 | `🔗 Found ${projectContext.similarProjects.length} similar project(s) in knowledge graph`,
339 | );
340 | }
341 |
342 | if (context?.meta?.progressToken) {
343 | await context.meta.reportProgress?.({
344 | progress: 100,
345 | total: 100,
346 | });
347 | }
348 |
349 | const executionTime = Date.now() - startTime;
350 | await context?.info?.(
351 | `✅ Analysis complete! Processed ${
352 | analysis.structure.totalFiles
353 | } files in ${Math.round(executionTime / 1000)}s`,
354 | );
355 |
356 | const response: MCPToolResponse<RepositoryAnalysis> = {
357 | success: true,
358 | data: analysis,
359 | metadata: {
360 | toolVersion: "1.0.0",
361 | executionTime,
362 | timestamp: new Date().toISOString(),
363 | analysisId: analysis.id,
364 | ...(intelligentAnalysis && { intelligentAnalysis }),
365 | },
366 | recommendations: [
367 | {
368 | type: "info",
369 | title: "Analysis Complete",
370 | description: `Successfully analyzed ${analysis.structure.totalFiles} files across ${analysis.structure.totalDirectories} directories`,
371 | },
372 | ...(contextInfo.length > 0
373 | ? [
374 | {
375 | type: "info" as const,
376 | title: "Historical Context",
377 | description: contextInfo.join("\n"),
378 | },
379 | ]
380 | : []),
381 | ...(intelligentAnalysis?.recommendations &&
382 | intelligentAnalysis.recommendations.length > 0
383 | ? [
384 | {
385 | type: "info" as const,
386 | title: "AI Recommendations",
387 | description: intelligentAnalysis.recommendations.join("\n• "),
388 | },
389 | ]
390 | : []),
391 | ...(intelligentAnalysis?.similarProjects &&
392 | intelligentAnalysis.similarProjects.length > 0
393 | ? [
394 | {
395 | type: "info" as const,
396 | title: "Similar Projects",
397 | description: intelligentAnalysis.similarProjects
398 | .map(
399 | (p: any) =>
400 | `${p.path} (${p.similarity} similar, ${p.technologies})`,
401 | )
402 | .join("\n"),
403 | },
404 | ]
405 | : []),
406 | ],
407 | nextSteps: [
408 | ...(analysis.documentation.hasReadme
409 | ? [
410 | {
411 | action: "Analyze README Quality",
412 | toolRequired: "analyze_readme",
413 | description:
414 | "Evaluate README completeness and suggest improvements",
415 | priority: "medium" as const,
416 | },
417 | ]
418 | : []),
419 | {
420 | action: "Get SSG Recommendation",
421 | toolRequired: "recommend_ssg",
422 | description: `Use analysis ID: ${analysis.id}`,
423 | priority: "high",
424 | },
425 | ],
426 | };
427 |
428 | return formatMCPResponse(response);
429 | } catch (error) {
430 | const errorResponse: MCPToolResponse = {
431 | success: false,
432 | error: {
433 | code: "ANALYSIS_FAILED",
434 | message: `Failed to analyze repository: ${error}`,
435 | resolution: "Ensure the repository path exists and is accessible",
436 | },
437 | metadata: {
438 | toolVersion: "1.0.0",
439 | executionTime: Date.now() - startTime,
440 | timestamp: new Date().toISOString(),
441 | },
442 | };
443 | return formatMCPResponse(errorResponse);
444 | }
445 | }
446 |
447 | // Helper function to generate unique analysis ID
448 | /**
449 | * Generates a unique analysis ID for tracking repository analyses.
450 | *
451 | * Creates a unique identifier combining timestamp and random string for
452 | * tracking individual repository analysis sessions and linking them to
453 | * recommendations and deployments.
454 | *
455 | * @returns A unique analysis identifier string
456 | *
457 | * @example
458 | * ```typescript
459 | * const analysisId = generateAnalysisId();
460 | * // Returns: "abc123def456"
461 | * ```
462 | *
463 | * @since 1.0.0
464 | */
465 | function generateAnalysisId(): string {
466 | const timestamp = Date.now().toString(36);
467 | const random = Math.random().toString(36).substring(2, 7);
468 | return `analysis_${timestamp}_${random}`;
469 | }
470 |
471 | // Map file extensions to languages
472 | function getLanguageFromExtension(ext: string): string | null {
473 | const languageMap: Record<string, string> = {
474 | ".js": "javascript",
475 | ".jsx": "javascript",
476 | ".ts": "typescript",
477 | ".tsx": "typescript",
478 | ".py": "python",
479 | ".rb": "ruby",
480 | ".go": "go",
481 | ".java": "java",
482 | ".c": "c",
483 | ".cpp": "cpp",
484 | ".cs": "csharp",
485 | ".php": "php",
486 | ".rs": "rust",
487 | ".kt": "kotlin",
488 | ".swift": "swift",
489 | ".scala": "scala",
490 | ".sh": "shell",
491 | ".bash": "shell",
492 | ".zsh": "shell",
493 | ".fish": "shell",
494 | ".ps1": "powershell",
495 | ".r": "r",
496 | ".sql": "sql",
497 | ".html": "html",
498 | ".css": "css",
499 | ".scss": "scss",
500 | ".sass": "sass",
501 | ".less": "less",
502 | ".vue": "vue",
503 | ".svelte": "svelte",
504 | ".dart": "dart",
505 | ".lua": "lua",
506 | ".pl": "perl",
507 | ".elm": "elm",
508 | ".clj": "clojure",
509 | ".ex": "elixir",
510 | ".exs": "elixir",
511 | ".erl": "erlang",
512 | ".hrl": "erlang",
513 | ".hs": "haskell",
514 | ".ml": "ocaml",
515 | ".fs": "fsharp",
516 | ".nim": "nim",
517 | ".cr": "crystal",
518 | ".d": "d",
519 | ".jl": "julia",
520 | ".zig": "zig",
521 | };
522 |
523 | return languageMap[ext] || null;
524 | }
525 |
526 | // Analyze repository structure
527 | /**
528 | * Analyzes the structural characteristics of a repository.
529 | *
530 | * Performs comprehensive structural analysis including file counting, directory
531 | * structure analysis, language detection, and identification of key project
532 | * components like tests, CI/CD, and documentation.
533 | *
534 | * @param repoPath - The file system path to the repository
535 | * @param depth - Analysis depth level affecting thoroughness and performance
536 | *
537 | * @returns Promise resolving to repository structure analysis results
538 | * @returns totalFiles - Total number of files in the repository
539 | * @returns totalDirectories - Total number of directories
540 | * @returns languages - Mapping of file extensions to counts
541 | * @returns hasTests - Whether test files/directories are present
542 | * @returns hasCI - Whether CI/CD configuration files are present
543 | * @returns hasDocs - Whether documentation files are present
544 | *
545 | * @throws {Error} When repository structure cannot be analyzed
546 | *
547 | * @example
548 | * ```typescript
549 | * const structure = await analyzeStructure("/path/to/repo", "standard");
550 | * console.log(`Found ${structure.totalFiles} files in ${structure.totalDirectories} directories`);
551 | * ```
552 | *
553 | * @since 1.0.0
554 | */
555 | async function analyzeStructure(
556 | repoPath: string,
557 | depth: "quick" | "standard" | "deep",
558 | ): Promise<RepositoryAnalysis["structure"]> {
559 | const stats = {
560 | totalFiles: 0,
561 | totalDirectories: 0,
562 | languages: {} as Record<string, number>,
563 | hasTests: false,
564 | hasCI: false,
565 | hasDocs: false,
566 | };
567 |
568 | const maxDepth = depth === "quick" ? 2 : depth === "standard" ? 5 : 10;
569 |
570 | async function walkDirectory(
571 | dirPath: string,
572 | currentDepth: number = 0,
573 | ): Promise<void> {
574 | if (currentDepth > maxDepth) return;
575 |
576 | try {
577 | const entries = await fs.readdir(dirPath, { withFileTypes: true });
578 |
579 | for (const entry of entries) {
580 | const fullPath = path.join(dirPath, entry.name);
581 |
582 | if (entry.isDirectory()) {
583 | stats.totalDirectories++;
584 |
585 | // Check for special directories
586 | if (
587 | entry.name.includes("test") ||
588 | entry.name.includes("spec") ||
589 | entry.name === "__tests__"
590 | ) {
591 | stats.hasTests = true;
592 | }
593 | if (
594 | entry.name === ".github" ||
595 | entry.name === ".gitlab-ci" ||
596 | entry.name === ".circleci"
597 | ) {
598 | stats.hasCI = true;
599 | }
600 | if (
601 | entry.name === "docs" ||
602 | entry.name === "documentation" ||
603 | entry.name === "doc"
604 | ) {
605 | stats.hasDocs = true;
606 | }
607 |
608 | // Skip node_modules and other common ignored directories
609 | if (
610 | ![
611 | "node_modules",
612 | ".git",
613 | "dist",
614 | "build",
615 | ".next",
616 | ".nuxt",
617 | ].includes(entry.name)
618 | ) {
619 | await walkDirectory(fullPath, currentDepth + 1);
620 | }
621 | } else if (entry.isFile()) {
622 | // Skip hidden files (starting with .)
623 | if (!entry.name.startsWith(".")) {
624 | stats.totalFiles++;
625 |
626 | // Track languages by file extension
627 | const ext = path.extname(entry.name).toLowerCase();
628 | if (ext && getLanguageFromExtension(ext)) {
629 | stats.languages[ext] = (stats.languages[ext] || 0) + 1;
630 | }
631 |
632 | // Check for CI files
633 | if (
634 | entry.name.match(/\.(yml|yaml)$/) &&
635 | entry.name.includes("ci")
636 | ) {
637 | stats.hasCI = true;
638 | }
639 |
640 | // Check for test files
641 | if (entry.name.includes("test") || entry.name.includes("spec")) {
642 | stats.hasTests = true;
643 | }
644 | }
645 | }
646 | }
647 | } catch (error) {
648 | // Skip directories we can't read
649 | }
650 | }
651 |
652 | await walkDirectory(repoPath);
653 | return stats;
654 | }
655 |
656 | // Analyze project dependencies
657 | async function analyzeDependencies(
658 | repoPath: string,
659 | ): Promise<RepositoryAnalysis["dependencies"]> {
660 | const result: RepositoryAnalysis["dependencies"] = {
661 | ecosystem: "unknown",
662 | packages: [],
663 | devPackages: [],
664 | };
665 |
666 | try {
667 | // Check for package.json (JavaScript/TypeScript)
668 | const packageJsonPath = path.join(repoPath, "package.json");
669 | try {
670 | const packageJsonContent = await fs.readFile(packageJsonPath, "utf-8");
671 | const packageJson = JSON.parse(packageJsonContent);
672 | result.ecosystem = "javascript";
673 | result.packages = Object.keys(packageJson.dependencies || {});
674 | result.devPackages = Object.keys(packageJson.devDependencies || {});
675 | return result;
676 | } catch {
677 | // Continue to check other ecosystems
678 | }
679 |
680 | // Check for requirements.txt or pyproject.toml (Python)
681 | const requirementsPath = path.join(repoPath, "requirements.txt");
682 | const pyprojectPath = path.join(repoPath, "pyproject.toml");
683 | try {
684 | try {
685 | const requirementsContent = await fs.readFile(
686 | requirementsPath,
687 | "utf-8",
688 | );
689 | result.ecosystem = "python";
690 | result.packages = requirementsContent
691 | .split("\n")
692 | .filter((line) => line.trim() && !line.startsWith("#"))
693 | .map((line) =>
694 | line.split("==")[0].split(">=")[0].split("<=")[0].trim(),
695 | );
696 | return result;
697 | } catch {
698 | const pyprojectContent = await fs.readFile(pyprojectPath, "utf-8");
699 | result.ecosystem = "python";
700 | // Basic parsing for pyproject.toml dependencies
701 | const dependencyMatches = pyprojectContent.match(
702 | /dependencies\s*=\s*\[([\s\S]*?)\]/,
703 | );
704 | if (dependencyMatches) {
705 | result.packages = dependencyMatches[1]
706 | .split(",")
707 | .map(
708 | (dep) =>
709 | dep
710 | .trim()
711 | .replace(/["']/g, "")
712 | .split("==")[0]
713 | .split(">=")[0]
714 | .split("<=")[0],
715 | )
716 | .filter((dep) => dep.length > 0);
717 | }
718 | return result;
719 | }
720 | } catch {
721 | // Continue to check other ecosystems
722 | }
723 |
724 | // Check for Gemfile (Ruby)
725 | const gemfilePath = path.join(repoPath, "Gemfile");
726 | try {
727 | const gemfileContent = await fs.readFile(gemfilePath, "utf-8");
728 | result.ecosystem = "ruby";
729 | const gemMatches = gemfileContent.match(/gem\s+['"]([^'"]+)['"]/g);
730 | if (gemMatches) {
731 | result.packages = gemMatches.map(
732 | (match) => match.match(/gem\s+['"]([^'"]+)['"]/)![1],
733 | );
734 | }
735 | return result;
736 | } catch {
737 | // Continue to check other ecosystems
738 | }
739 |
740 | // Check for go.mod (Go)
741 | const goModPath = path.join(repoPath, "go.mod");
742 | try {
743 | const goModContent = await fs.readFile(goModPath, "utf-8");
744 | result.ecosystem = "go";
745 | const requireMatches = goModContent.match(/require\s+\(([\s\S]*?)\)/);
746 | if (requireMatches) {
747 | result.packages = requireMatches[1]
748 | .split("\n")
749 | .map((line) => line.trim().split(" ")[0])
750 | .filter((pkg) => pkg && !pkg.startsWith("//"));
751 | }
752 | return result;
753 | } catch {
754 | // No recognized dependency files found
755 | }
756 |
757 | return result;
758 | } catch (error) {
759 | return result;
760 | }
761 | }
762 |
763 | // Analyze documentation structure
764 | async function analyzeDocumentation(
765 | repoPath: string,
766 | ): Promise<RepositoryAnalysis["documentation"]> {
767 | const result: RepositoryAnalysis["documentation"] = {
768 | hasReadme: false,
769 | hasContributing: false,
770 | hasLicense: false,
771 | existingDocs: [],
772 | estimatedComplexity: "simple",
773 | };
774 |
775 | try {
776 | const entries = await fs.readdir(repoPath);
777 |
778 | // Check for standard files
779 | for (const entry of entries) {
780 | const lowerEntry = entry.toLowerCase();
781 | if (lowerEntry.startsWith("readme")) {
782 | result.hasReadme = true;
783 | } else if (lowerEntry.startsWith("contributing")) {
784 | result.hasContributing = true;
785 | } else if (lowerEntry.startsWith("license")) {
786 | result.hasLicense = true;
787 | }
788 | }
789 |
790 | // Find documentation files
791 | const docExtensions = [".md", ".rst", ".txt", ".adoc"];
792 | const commonDocDirs = ["docs", "documentation", "doc", "wiki"];
793 |
794 | // Check root directory for docs
795 | for (const entry of entries) {
796 | const entryPath = path.join(repoPath, entry);
797 | const stat = await fs.stat(entryPath);
798 |
799 | if (
800 | stat.isFile() &&
801 | docExtensions.some((ext) => entry.toLowerCase().endsWith(ext))
802 | ) {
803 | result.existingDocs.push(entry);
804 | } else if (
805 | stat.isDirectory() &&
806 | commonDocDirs.includes(entry.toLowerCase())
807 | ) {
808 | try {
809 | const docFiles = await fs.readdir(entryPath);
810 | for (const docFile of docFiles) {
811 | if (
812 | docExtensions.some((ext) => docFile.toLowerCase().endsWith(ext))
813 | ) {
814 | result.existingDocs.push(path.join(entry, docFile));
815 | }
816 | }
817 | } catch {
818 | // Skip if can't read directory
819 | }
820 | }
821 | }
822 |
823 | // Estimate complexity based on documentation found
824 | const docCount = result.existingDocs.length;
825 | if (docCount <= 3) {
826 | result.estimatedComplexity = "simple";
827 | } else if (docCount <= 10) {
828 | result.estimatedComplexity = "moderate";
829 | } else {
830 | result.estimatedComplexity = "complex";
831 | }
832 |
833 | // Extract comprehensive documentation content
834 | try {
835 | result.extractedContent = await extractRepositoryContent(repoPath);
836 | } catch (error) {
837 | console.warn("Failed to extract repository content:", error);
838 | // Continue without extracted content
839 | }
840 |
841 | return result;
842 | } catch (error) {
843 | return result;
844 | }
845 | }
846 |
847 | // Helper function to count languages in a directory
848 | async function countLanguagesInDirectory(
849 | dirPath: string,
850 | languages: Record<string, number>,
851 | depth: number = 0,
852 | ): Promise<void> {
853 | if (depth > 3) return; // Limit depth for performance
854 |
855 | try {
856 | const entries = await fs.readdir(dirPath, { withFileTypes: true });
857 |
858 | for (const entry of entries) {
859 | if (entry.isFile()) {
860 | const ext = path.extname(entry.name).toLowerCase();
861 | if (ext && getLanguageFromExtension(ext)) {
862 | languages[ext] = (languages[ext] || 0) + 1;
863 | }
864 | } else if (
865 | entry.isDirectory() &&
866 | !["node_modules", ".git", "dist"].includes(entry.name)
867 | ) {
868 | await countLanguagesInDirectory(
869 | path.join(dirPath, entry.name),
870 | languages,
871 | depth + 1,
872 | );
873 | }
874 | }
875 | } catch {
876 | // Skip directories we can't read
877 | }
878 | }
879 |
880 | // Generate recommendations based on analysis
881 | async function generateRecommendations(
882 | repoPath: string,
883 | ): Promise<RepositoryAnalysis["recommendations"]> {
884 | const result: RepositoryAnalysis["recommendations"] = {
885 | primaryLanguage: "unknown",
886 | projectType: "unknown",
887 | teamSize: "solo",
888 | };
889 |
890 | try {
891 | // Determine primary language by counting files
892 | const languages: Record<string, number> = {};
893 | await countLanguagesInDirectory(repoPath, languages);
894 |
895 | // Find primary language
896 | let primaryExt = "";
897 | if (Object.keys(languages).length > 0) {
898 | primaryExt = Object.entries(languages).reduce((a, b) =>
899 | a[1] > b[1] ? a : b,
900 | )[0];
901 | const primaryLanguage = getLanguageFromExtension(primaryExt);
902 | result.primaryLanguage = primaryLanguage || "unknown";
903 | }
904 |
905 | // Determine project type based on files and structure
906 | const entries = await fs.readdir(repoPath);
907 | const hasPackageJson = entries.includes("package.json");
908 | const hasDockerfile = entries.includes("Dockerfile");
909 | const hasK8sFiles = entries.some(
910 | (entry) => entry.endsWith(".yaml") || entry.endsWith(".yml"),
911 | );
912 | const hasTests = entries.some(
913 | (entry) => entry.includes("test") || entry.includes("spec"),
914 | );
915 |
916 | if (hasPackageJson && entries.includes("src") && hasTests) {
917 | result.projectType = "library";
918 | } else if (hasDockerfile || hasK8sFiles) {
919 | result.projectType = "application";
920 | } else if (entries.includes("docs") || entries.includes("documentation")) {
921 | result.projectType = "documentation";
922 | } else if (hasTests && primaryExt && languages[primaryExt] > 10) {
923 | result.projectType = "application";
924 | } else {
925 | result.projectType = "script";
926 | }
927 |
928 | // Estimate team size based on complexity and structure
929 | const totalFiles = Object.values(languages).reduce(
930 | (sum, count) => sum + count,
931 | 0,
932 | );
933 | const hasCI = entries.some(
934 | (entry) => entry.includes(".github") || entry.includes(".gitlab"),
935 | );
936 | const hasContributing = entries.some((entry) =>
937 | entry.toLowerCase().includes("contributing"),
938 | );
939 |
940 | if (totalFiles > 100 || (hasCI && hasContributing)) {
941 | result.teamSize = "large";
942 | } else if (totalFiles > 50 || hasCI) {
943 | result.teamSize = "medium";
944 | } else if (totalFiles > 20 || hasTests) {
945 | result.teamSize = "small";
946 | } else {
947 | result.teamSize = "solo";
948 | }
949 |
950 | return result;
951 | } catch (error) {
952 | return result;
953 | }
954 | }
955 |
```
--------------------------------------------------------------------------------
/tests/tools/check-documentation-links.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { checkDocumentationLinks } from "../../src/tools/check-documentation-links.js";
2 | import { formatMCPResponse } from "../../src/types/api.js";
3 | import { writeFile, mkdir, rm } from "fs/promises";
4 | import { join } from "path";
5 |
6 | describe("checkDocumentationLinks", () => {
7 | const testDir = join(process.cwd(), "test-docs-temp");
8 |
9 | beforeEach(async () => {
10 | // Create test directory structure
11 | await mkdir(testDir, { recursive: true });
12 | await mkdir(join(testDir, "guides"), { recursive: true });
13 | await mkdir(join(testDir, "api"), { recursive: true });
14 | });
15 |
16 | afterEach(async () => {
17 | // Clean up test directory
18 | try {
19 | await rm(testDir, { recursive: true, force: true });
20 | } catch (error) {
21 | // Ignore cleanup errors
22 | }
23 | });
24 |
25 | describe("Input Validation", () => {
26 | test("should use default values for optional parameters", async () => {
27 | await writeFile(
28 | join(testDir, "README.md"),
29 | "# Test\n[Link](./guides/test.md)",
30 | );
31 | await writeFile(join(testDir, "guides", "test.md"), "# Guide");
32 |
33 | const result = await checkDocumentationLinks({
34 | documentation_path: testDir,
35 | });
36 |
37 | const formatted = formatMCPResponse(result);
38 | expect(formatted.isError).toBe(false);
39 | const contentText = formatted.content.map((c) => c.text).join(" ");
40 | expect(contentText).toContain('"totalLinks": 1');
41 | });
42 |
43 | test("should validate timeout_ms parameter", async () => {
44 | const result = await checkDocumentationLinks({
45 | documentation_path: testDir,
46 | timeout_ms: 500, // Below minimum
47 | });
48 |
49 | const formatted = formatMCPResponse(result);
50 | expect(formatted.isError).toBe(true);
51 | const contentText = formatted.content.map((c) => c.text).join(" ");
52 | expect(contentText).toContain(
53 | "Number must be greater than or equal to 1000",
54 | );
55 | });
56 |
57 | test("should validate max_concurrent_checks parameter", async () => {
58 | const result = await checkDocumentationLinks({
59 | documentation_path: testDir,
60 | max_concurrent_checks: 25, // Above maximum
61 | });
62 |
63 | const formatted = formatMCPResponse(result);
64 | expect(formatted.isError).toBe(true);
65 | const contentText = formatted.content.map((c) => c.text).join(" ");
66 | expect(contentText).toContain("Number must be less than or equal to 20");
67 | });
68 | });
69 |
70 | describe("File Scanning", () => {
71 | test("should find markdown files in nested directories", async () => {
72 | await writeFile(join(testDir, "README.md"), "# Root");
73 | await writeFile(join(testDir, "guides", "guide1.md"), "# Guide 1");
74 | await writeFile(join(testDir, "api", "reference.mdx"), "# API Reference");
75 |
76 | const result = await checkDocumentationLinks({
77 | documentation_path: testDir,
78 | check_external_links: false,
79 | });
80 |
81 | const formatted = formatMCPResponse(result);
82 | expect(formatted.isError).toBe(false);
83 | const contentText = formatted.content.map((c) => c.text).join(" ");
84 | expect(contentText).toContain('"filesScanned": 3');
85 | });
86 |
87 | test("should handle empty documentation directory", async () => {
88 | const result = await checkDocumentationLinks({
89 | documentation_path: testDir,
90 | });
91 |
92 | const formatted = formatMCPResponse(result);
93 | expect(formatted.isError).toBe(true);
94 | const contentText = formatted.content.map((c) => c.text).join(" ");
95 | expect(contentText).toContain("No documentation files found");
96 | });
97 |
98 | test("should handle non-existent directory", async () => {
99 | const result = await checkDocumentationLinks({
100 | documentation_path: "/non/existent/path",
101 | });
102 |
103 | const formatted = formatMCPResponse(result);
104 | expect(formatted.isError).toBe(true);
105 | });
106 | });
107 |
108 | describe("Link Extraction", () => {
109 | test("should extract markdown links", async () => {
110 | const content = `# Test Document
111 | [Internal Link](./guides/test.md)
112 | [External Link](https://example.com)
113 | `;
114 | await writeFile(join(testDir, "test.md"), content);
115 |
116 | const result = await checkDocumentationLinks({
117 | documentation_path: testDir,
118 | check_external_links: false,
119 | check_internal_links: true,
120 | });
121 |
122 | const formatted = formatMCPResponse(result);
123 | expect(formatted.isError).toBe(false);
124 | const contentText = formatted.content.map((c) => c.text).join(" ");
125 | expect(contentText).toContain('"totalLinks": 1');
126 | });
127 |
128 | test("should extract HTML links", async () => {
129 | const content = `# Test Document
130 | <a href="./guides/test.md">Internal Link</a>
131 | <a href="https://example.com">External Link</a>
132 | `;
133 | await writeFile(join(testDir, "test.md"), content);
134 |
135 | const result = await checkDocumentationLinks({
136 | documentation_path: testDir,
137 | check_external_links: false,
138 | });
139 |
140 | const formatted = formatMCPResponse(result);
141 | expect(formatted.isError).toBe(false);
142 | });
143 |
144 | test("should skip mailto and tel links", async () => {
145 | const content = `# Contact
146 | [Email](mailto:[email protected])
147 | [Phone](tel:+1234567890)
148 | [Valid Link](./test.md)
149 | `;
150 | await writeFile(join(testDir, "contact.md"), content);
151 | await writeFile(join(testDir, "test.md"), "# Test");
152 |
153 | const result = await checkDocumentationLinks({
154 | documentation_path: testDir,
155 | check_external_links: false,
156 | });
157 |
158 | const formatted = formatMCPResponse(result);
159 | expect(formatted.isError).toBe(false);
160 | // Should only check the valid link, not mailto/tel
161 | });
162 | });
163 |
164 | describe("Internal Link Checking", () => {
165 | test("should validate existing internal links", async () => {
166 | await writeFile(join(testDir, "README.md"), "[Valid](./guides/test.md)");
167 | await mkdir(join(testDir, "guides"), { recursive: true });
168 | await writeFile(join(testDir, "guides", "test.md"), "# Test");
169 |
170 | const result = await checkDocumentationLinks({
171 | documentation_path: testDir,
172 | check_external_links: false,
173 | check_internal_links: true,
174 | });
175 |
176 | const formatted = formatMCPResponse(result);
177 | expect(formatted.isError).toBe(false);
178 | const contentText = formatted.content.map((c) => c.text).join(" ");
179 | expect(contentText).toContain('"status": "valid"');
180 | });
181 |
182 | test("should detect broken internal links", async () => {
183 | await writeFile(join(testDir, "README.md"), "[Broken](./missing.md)");
184 |
185 | const result = await checkDocumentationLinks({
186 | documentation_path: testDir,
187 | check_external_links: false,
188 | check_internal_links: true,
189 | fail_on_broken_links: false,
190 | });
191 |
192 | const formatted = formatMCPResponse(result);
193 | expect(formatted.isError).toBe(false);
194 | const contentText = formatted.content.map((c) => c.text).join(" ");
195 | expect(contentText).toContain('"status": "broken"');
196 | });
197 |
198 | test("should handle relative path navigation", async () => {
199 | await writeFile(
200 | join(testDir, "guides", "guide1.md"),
201 | "[Back](../README.md)",
202 | );
203 | await writeFile(join(testDir, "README.md"), "# Root");
204 |
205 | const result = await checkDocumentationLinks({
206 | documentation_path: testDir,
207 | check_external_links: false,
208 | check_internal_links: true,
209 | });
210 |
211 | const formatted = formatMCPResponse(result);
212 | expect(formatted.isError).toBe(false);
213 | const contentText = formatted.content.map((c) => c.text).join(" ");
214 | expect(contentText).toContain('"status": "valid"');
215 | });
216 |
217 | test("should handle anchor links in internal files", async () => {
218 | await writeFile(
219 | join(testDir, "README.md"),
220 | "[Section](./guide.md#section)",
221 | );
222 | await writeFile(join(testDir, "guide.md"), "# Guide\n## Section");
223 |
224 | const result = await checkDocumentationLinks({
225 | documentation_path: testDir,
226 | check_external_links: false,
227 | check_internal_links: true,
228 | });
229 |
230 | const formatted = formatMCPResponse(result);
231 | expect(formatted.isError).toBe(false);
232 | });
233 | });
234 |
235 | describe("External Link Checking", () => {
236 | test("should skip external links when disabled", async () => {
237 | await writeFile(
238 | join(testDir, "README.md"),
239 | "[External](https://example.com)",
240 | );
241 |
242 | const result = await checkDocumentationLinks({
243 | documentation_path: testDir,
244 | check_external_links: false,
245 | });
246 |
247 | const formatted = formatMCPResponse(result);
248 | expect(formatted.isError).toBe(false);
249 | // Should have 0 links checked since external checking is disabled
250 | });
251 |
252 | test("should respect allowed domains", async () => {
253 | await writeFile(
254 | join(testDir, "README.md"),
255 | `
256 | [Allowed](https://github.com/test)
257 | [Not Allowed](https://example.com)
258 | `,
259 | );
260 |
261 | const result = await checkDocumentationLinks({
262 | documentation_path: testDir,
263 | check_external_links: true,
264 | allowed_domains: ["github.com"],
265 | });
266 |
267 | const formatted = formatMCPResponse(result);
268 | expect(formatted.isError).toBe(false);
269 | });
270 |
271 | test("should handle timeout for slow external links", async () => {
272 | await writeFile(
273 | join(testDir, "README.md"),
274 | "[Slow](https://httpstat.us/200?sleep=10000)",
275 | );
276 |
277 | const result = await checkDocumentationLinks({
278 | documentation_path: testDir,
279 | check_external_links: true,
280 | timeout_ms: 1000,
281 | });
282 |
283 | const formatted = formatMCPResponse(result);
284 | expect(formatted.isError).toBe(false);
285 | // Should timeout and mark as warning
286 | });
287 | });
288 |
289 | describe("Link Filtering", () => {
290 | test("should ignore links matching ignore patterns", async () => {
291 | await writeFile(
292 | join(testDir, "README.md"),
293 | `
294 | [Ignored](./temp/file.md)
295 | [Valid](./guides/test.md)
296 | `,
297 | );
298 | await writeFile(join(testDir, "guides", "test.md"), "# Test");
299 |
300 | const result = await checkDocumentationLinks({
301 | documentation_path: testDir,
302 | check_external_links: false,
303 | ignore_patterns: ["temp/"],
304 | });
305 |
306 | const formatted = formatMCPResponse(result);
307 | expect(formatted.isError).toBe(false);
308 | // Should only check the valid link, ignore the temp/ link
309 | });
310 |
311 | test("should filter by link types", async () => {
312 | await writeFile(
313 | join(testDir, "README.md"),
314 | `
315 | [Internal](./test.md)
316 | [External](https://example.com)
317 | [Anchor](#section)
318 | `,
319 | );
320 | await writeFile(join(testDir, "test.md"), "# Test");
321 |
322 | const result = await checkDocumentationLinks({
323 | documentation_path: testDir,
324 | check_external_links: false,
325 | check_internal_links: true,
326 | check_anchor_links: false,
327 | });
328 |
329 | const formatted = formatMCPResponse(result);
330 | expect(formatted.isError).toBe(false);
331 | // Should only check internal links
332 | });
333 | });
334 |
335 | describe("Failure Modes", () => {
336 | test("should fail when fail_on_broken_links is true and links are broken", async () => {
337 | await writeFile(join(testDir, "README.md"), "[Broken](./missing.md)");
338 |
339 | const result = await checkDocumentationLinks({
340 | documentation_path: testDir,
341 | check_external_links: false,
342 | fail_on_broken_links: true,
343 | });
344 |
345 | const formatted = formatMCPResponse(result);
346 | expect(formatted.isError).toBe(true);
347 | const contentText = formatted.content.map((c) => c.text).join(" ");
348 | expect(contentText).toContain("Found 1 broken links");
349 | });
350 |
351 | test("should not fail when fail_on_broken_links is false", async () => {
352 | await writeFile(join(testDir, "README.md"), "[Broken](./missing.md)");
353 |
354 | const result = await checkDocumentationLinks({
355 | documentation_path: testDir,
356 | check_external_links: false,
357 | fail_on_broken_links: false,
358 | });
359 |
360 | const formatted = formatMCPResponse(result);
361 | expect(formatted.isError).toBe(false);
362 | });
363 | });
364 |
365 | describe("Report Generation", () => {
366 | test("should generate comprehensive report with summary", async () => {
367 | await writeFile(
368 | join(testDir, "README.md"),
369 | `
370 | [Valid Internal](./test.md)
371 | [Broken Internal](./missing.md)
372 | `,
373 | );
374 | await writeFile(join(testDir, "test.md"), "# Test");
375 |
376 | const result = await checkDocumentationLinks({
377 | documentation_path: testDir,
378 | check_external_links: false,
379 | fail_on_broken_links: false,
380 | });
381 |
382 | const formatted = formatMCPResponse(result);
383 | expect(formatted.isError).toBe(false);
384 | const contentText = formatted.content.map((c) => c.text).join(" ");
385 | expect(contentText).toContain('"summary"');
386 | expect(contentText).toContain('"results"');
387 | expect(contentText).toContain('"recommendations"');
388 | expect(contentText).toContain('"totalLinks": 2');
389 | });
390 |
391 | test("should include execution metrics", async () => {
392 | await writeFile(join(testDir, "README.md"), "[Test](./test.md)");
393 | await writeFile(join(testDir, "test.md"), "# Test");
394 |
395 | const result = await checkDocumentationLinks({
396 | documentation_path: testDir,
397 | check_external_links: false,
398 | });
399 |
400 | const formatted = formatMCPResponse(result);
401 | expect(formatted.isError).toBe(false);
402 | const contentText = formatted.content.map((c) => c.text).join(" ");
403 | expect(contentText).toContain('"executionTime"');
404 | expect(contentText).toContain('"filesScanned"');
405 | });
406 |
407 | test("should provide recommendations based on results", async () => {
408 | await writeFile(join(testDir, "README.md"), "[Valid](./test.md)");
409 | await writeFile(join(testDir, "test.md"), "# Test");
410 |
411 | const result = await checkDocumentationLinks({
412 | documentation_path: testDir,
413 | check_external_links: false,
414 | });
415 |
416 | const formatted = formatMCPResponse(result);
417 | expect(formatted.isError).toBe(false);
418 | const contentText = formatted.content.map((c) => c.text).join(" ");
419 | expect(contentText).toContain(
420 | "All links are valid - excellent documentation quality!",
421 | );
422 | });
423 | });
424 |
425 | describe("Concurrency Control", () => {
426 | test("should respect max_concurrent_checks limit", async () => {
427 | // Create multiple files with links
428 | for (let i = 0; i < 10; i++) {
429 | await writeFile(
430 | join(testDir, `file${i}.md`),
431 | `[Link](./target${i}.md)`,
432 | );
433 | await writeFile(join(testDir, `target${i}.md`), `# Target ${i}`);
434 | }
435 |
436 | const result = await checkDocumentationLinks({
437 | documentation_path: testDir,
438 | check_external_links: false,
439 | max_concurrent_checks: 2,
440 | });
441 |
442 | const formatted = formatMCPResponse(result);
443 | expect(formatted.isError).toBe(false);
444 | // Should complete successfully with concurrency control
445 | });
446 | });
447 |
448 | describe("Edge Cases", () => {
449 | test("should handle files with no links", async () => {
450 | await writeFile(
451 | join(testDir, "README.md"),
452 | "# No Links Here\nJust plain text.",
453 | );
454 |
455 | const result = await checkDocumentationLinks({
456 | documentation_path: testDir,
457 | });
458 |
459 | const formatted = formatMCPResponse(result);
460 | expect(formatted.isError).toBe(false);
461 | const contentText = formatted.content.map((c) => c.text).join(" ");
462 | expect(contentText).toContain('"totalLinks": 0');
463 | });
464 |
465 | test("should handle malformed markdown", async () => {
466 | await writeFile(
467 | join(testDir, "README.md"),
468 | `
469 | # Malformed
470 | [Incomplete link](
471 | [Missing closing](test.md
472 | [Valid](./test.md)
473 | `,
474 | );
475 | await writeFile(join(testDir, "test.md"), "# Test");
476 |
477 | const result = await checkDocumentationLinks({
478 | documentation_path: testDir,
479 | check_external_links: false,
480 | });
481 |
482 | const formatted = formatMCPResponse(result);
483 | expect(formatted.isError).toBe(false);
484 | // Should handle malformed links gracefully
485 | });
486 |
487 | test("should handle binary files gracefully", async () => {
488 | await writeFile(join(testDir, "README.md"), "[Test](./test.md)");
489 | await writeFile(join(testDir, "test.md"), "# Test");
490 | // Create a binary file that should be ignored
491 | await writeFile(
492 | join(testDir, "image.png"),
493 | Buffer.from([0x89, 0x50, 0x4e, 0x47]),
494 | );
495 |
496 | const result = await checkDocumentationLinks({
497 | documentation_path: testDir,
498 | check_external_links: false,
499 | });
500 |
501 | const formatted = formatMCPResponse(result);
502 | expect(formatted.isError).toBe(false);
503 | // Should ignore binary files and process markdown files
504 | });
505 | });
506 |
507 | describe("Advanced Branch Coverage Tests", () => {
508 | test("should handle reference links", async () => {
509 | const content = `# Test Document
510 | [Reference Link][ref1]
511 | [Another Reference][ref2]
512 |
513 | [ref1]: ./guides/test.md
514 | [ref2]: https://example.com
515 | `;
516 | await writeFile(join(testDir, "test.md"), content);
517 | await writeFile(join(testDir, "guides", "test.md"), "# Guide");
518 |
519 | const result = await checkDocumentationLinks({
520 | documentation_path: testDir,
521 | check_external_links: false,
522 | });
523 |
524 | const formatted = formatMCPResponse(result);
525 | expect(formatted.isError).toBe(false);
526 | const contentText = formatted.content.map((c) => c.text).join(" ");
527 | expect(contentText).toContain('"totalLinks": 1');
528 | });
529 |
530 | test("should handle anchor-only links", async () => {
531 | const content = `# Test Document
532 | [Anchor Only](#section)
533 | [Valid Link](./test.md)
534 | `;
535 | await writeFile(join(testDir, "README.md"), content);
536 | await writeFile(join(testDir, "test.md"), "# Test");
537 |
538 | const result = await checkDocumentationLinks({
539 | documentation_path: testDir,
540 | check_external_links: false,
541 | check_anchor_links: true,
542 | });
543 |
544 | const formatted = formatMCPResponse(result);
545 | expect(formatted.isError).toBe(false);
546 | });
547 |
548 | test("should handle empty URL in links", async () => {
549 | const content = `# Test Document
550 | [Empty Link]()
551 | [Valid Link](./test.md)
552 | `;
553 | await writeFile(join(testDir, "README.md"), content);
554 | await writeFile(join(testDir, "test.md"), "# Test");
555 |
556 | const result = await checkDocumentationLinks({
557 | documentation_path: testDir,
558 | check_external_links: false,
559 | });
560 |
561 | const formatted = formatMCPResponse(result);
562 | expect(formatted.isError).toBe(false);
563 | });
564 |
565 | test("should handle different internal link path formats", async () => {
566 | await mkdir(join(testDir, "subdir"), { recursive: true });
567 | await mkdir(join(testDir, "guides"), { recursive: true });
568 | await writeFile(
569 | join(testDir, "subdir", "nested.md"),
570 | `
571 | [Current Dir](./file.md)
572 | [Parent Dir](../README.md)
573 | [Absolute](/guides/test.md)
574 | [Relative](file.md)
575 | `,
576 | );
577 | await writeFile(join(testDir, "subdir", "file.md"), "# File");
578 | await writeFile(join(testDir, "README.md"), "# Root");
579 | await writeFile(join(testDir, "guides", "test.md"), "# Guide");
580 |
581 | const result = await checkDocumentationLinks({
582 | documentation_path: testDir,
583 | check_external_links: false,
584 | });
585 |
586 | const formatted = formatMCPResponse(result);
587 | expect(formatted.isError).toBe(false);
588 | });
589 |
590 | test("should handle external link domain filtering", async () => {
591 | await writeFile(
592 | join(testDir, "README.md"),
593 | `
594 | [GitHub](https://github.com/test)
595 | [Subdomain](https://api.github.com/test)
596 | [Not Allowed](https://example.com)
597 | `,
598 | );
599 |
600 | const result = await checkDocumentationLinks({
601 | documentation_path: testDir,
602 | check_external_links: true,
603 | allowed_domains: ["github.com"],
604 | });
605 |
606 | const formatted = formatMCPResponse(result);
607 | expect(formatted.isError).toBe(false);
608 | });
609 |
610 | test("should handle external link fetch errors", async () => {
611 | await writeFile(
612 | join(testDir, "README.md"),
613 | "[Invalid URL](https://invalid-domain-that-does-not-exist-12345.com)",
614 | );
615 |
616 | const result = await checkDocumentationLinks({
617 | documentation_path: testDir,
618 | check_external_links: true,
619 | timeout_ms: 2000,
620 | });
621 |
622 | const formatted = formatMCPResponse(result);
623 | expect(formatted.isError).toBe(false);
624 | const contentText = formatted.content.map((c) => c.text).join(" ");
625 | expect(contentText).toContain('"status": "broken"');
626 | });
627 |
628 | test("should handle HTTP error status codes", async () => {
629 | await writeFile(
630 | join(testDir, "README.md"),
631 | "[Not Found](https://httpstat.us/404)",
632 | );
633 |
634 | const result = await checkDocumentationLinks({
635 | documentation_path: testDir,
636 | check_external_links: true,
637 | timeout_ms: 5000,
638 | });
639 |
640 | const formatted = formatMCPResponse(result);
641 | expect(formatted.isError).toBe(false);
642 | });
643 |
644 | test("should handle directory scanning errors", async () => {
645 | // Create a directory with restricted permissions
646 | await mkdir(join(testDir, "restricted"), { recursive: true });
647 | await writeFile(join(testDir, "README.md"), "[Test](./test.md)");
648 | await writeFile(join(testDir, "test.md"), "# Test");
649 |
650 | const result = await checkDocumentationLinks({
651 | documentation_path: testDir,
652 | check_external_links: false,
653 | });
654 |
655 | const formatted = formatMCPResponse(result);
656 | expect(formatted.isError).toBe(false);
657 | });
658 |
659 | test("should handle file reading errors gracefully", async () => {
660 | await writeFile(join(testDir, "README.md"), "[Test](./test.md)");
661 | await writeFile(join(testDir, "test.md"), "# Test");
662 | // Create a file that might cause reading issues
663 | await writeFile(join(testDir, "problematic.md"), "# Test\x00\x01\x02");
664 |
665 | const result = await checkDocumentationLinks({
666 | documentation_path: testDir,
667 | check_external_links: false,
668 | });
669 |
670 | const formatted = formatMCPResponse(result);
671 | expect(formatted.isError).toBe(false);
672 | });
673 |
674 | test("should generate recommendations for large link counts", async () => {
675 | let content = "# Test Document\n";
676 | for (let i = 0; i < 101; i++) {
677 | content += `[Link ${i}](./file${i}.md)\n`;
678 | await writeFile(join(testDir, `file${i}.md`), `# File ${i}`);
679 | }
680 | await writeFile(join(testDir, "README.md"), content);
681 |
682 | const result = await checkDocumentationLinks({
683 | documentation_path: testDir,
684 | check_external_links: false,
685 | });
686 |
687 | const formatted = formatMCPResponse(result);
688 | expect(formatted.isError).toBe(false);
689 | const contentText = formatted.content.map((c) => c.text).join(" ");
690 | expect(contentText).toContain(
691 | "Consider implementing automated link checking in CI/CD pipeline",
692 | );
693 | });
694 |
695 | test("should handle mixed link types with warnings", async () => {
696 | await writeFile(
697 | join(testDir, "README.md"),
698 | `
699 | [Valid](./test.md)
700 | [Broken](./missing.md)
701 | [Timeout](https://httpstat.us/200?sleep=10000)
702 | `,
703 | );
704 | await writeFile(join(testDir, "test.md"), "# Test");
705 |
706 | const result = await checkDocumentationLinks({
707 | documentation_path: testDir,
708 | check_external_links: true,
709 | timeout_ms: 1000,
710 | fail_on_broken_links: false,
711 | });
712 |
713 | const formatted = formatMCPResponse(result);
714 | expect(formatted.isError).toBe(false);
715 | const contentText = formatted.content.map((c) => c.text).join(" ");
716 | expect(contentText).toContain('"brokenLinks"');
717 | expect(contentText).toContain('"warningLinks"');
718 | });
719 |
720 | test("should handle node_modules and hidden directory exclusion", async () => {
721 | await mkdir(join(testDir, "node_modules"), { recursive: true });
722 | await mkdir(join(testDir, ".hidden"), { recursive: true });
723 | await writeFile(
724 | join(testDir, "node_modules", "package.md"),
725 | "# Should be ignored",
726 | );
727 | await writeFile(
728 | join(testDir, ".hidden", "secret.md"),
729 | "# Should be ignored",
730 | );
731 | await writeFile(join(testDir, "README.md"), "[Test](./test.md)");
732 | await writeFile(join(testDir, "test.md"), "# Test");
733 |
734 | const result = await checkDocumentationLinks({
735 | documentation_path: testDir,
736 | check_external_links: false,
737 | });
738 |
739 | const formatted = formatMCPResponse(result);
740 | expect(formatted.isError).toBe(false);
741 | const contentText = formatted.content.map((c) => c.text).join(" ");
742 | expect(contentText).toContain('"filesScanned": 2'); // Only README.md and test.md
743 | });
744 |
745 | test("should handle different markdown file extensions", async () => {
746 | await writeFile(join(testDir, "README.md"), "[MD](./test.md)");
747 | await writeFile(join(testDir, "guide.mdx"), "[MDX](./test.md)");
748 | await writeFile(join(testDir, "doc.markdown"), "[MARKDOWN](./test.md)");
749 | await writeFile(join(testDir, "test.md"), "# Test");
750 |
751 | const result = await checkDocumentationLinks({
752 | documentation_path: testDir,
753 | check_external_links: false,
754 | });
755 |
756 | const formatted = formatMCPResponse(result);
757 | expect(formatted.isError).toBe(false);
758 | const contentText = formatted.content.map((c) => c.text).join(" ");
759 | expect(contentText).toContain('"filesScanned": 4');
760 | });
761 | });
762 |
763 | describe("Special Link Types", () => {
764 | test("should skip mailto links during filtering", async () => {
765 | await writeFile(
766 | join(testDir, "README.md"),
767 | "[Email](mailto:[email protected])\n[Valid](./guide.md)",
768 | );
769 | await writeFile(join(testDir, "guide.md"), "# Guide");
770 |
771 | const result = await checkDocumentationLinks({
772 | documentation_path: testDir,
773 | check_external_links: true,
774 | check_internal_links: true,
775 | });
776 |
777 | const formatted = formatMCPResponse(result);
778 | expect(formatted.isError).toBe(false);
779 | const contentText = formatted.content.map((c) => c.text).join(" ");
780 | // Should find only the internal link, mailto is filtered
781 | expect(contentText).toContain('"totalLinks": 1');
782 | expect(contentText).toContain("./guide.md");
783 | });
784 |
785 | test("should skip tel links during filtering", async () => {
786 | await writeFile(
787 | join(testDir, "README.md"),
788 | "[Phone](tel:+1234567890)\n[Valid](./guide.md)",
789 | );
790 | await writeFile(join(testDir, "guide.md"), "# Guide");
791 |
792 | const result = await checkDocumentationLinks({
793 | documentation_path: testDir,
794 | check_external_links: true,
795 | check_internal_links: true,
796 | });
797 |
798 | const formatted = formatMCPResponse(result);
799 | expect(formatted.isError).toBe(false);
800 | const contentText = formatted.content.map((c) => c.text).join(" ");
801 | // Should find only the internal link, tel is filtered
802 | expect(contentText).toContain('"totalLinks": 1');
803 | expect(contentText).toContain("./guide.md");
804 | });
805 |
806 | test("should check anchor links when enabled and file exists", async () => {
807 | await writeFile(
808 | join(testDir, "README.md"),
809 | "[Guide Anchor](./guide.md#introduction)",
810 | );
811 | await writeFile(
812 | join(testDir, "guide.md"),
813 | "# Guide\n\n## Introduction\n\nContent here.",
814 | );
815 |
816 | const result = await checkDocumentationLinks({
817 | documentation_path: testDir,
818 | check_anchor_links: true,
819 | check_internal_links: true,
820 | check_external_links: false,
821 | });
822 |
823 | const formatted = formatMCPResponse(result);
824 | expect(formatted.isError).toBe(false);
825 | const contentText = formatted.content.map((c) => c.text).join(" ");
826 | expect(contentText).toContain('"totalLinks": 1');
827 | expect(contentText).toContain("./guide.md#introduction");
828 | });
829 |
830 | test("should handle anchor links to other files", async () => {
831 | await writeFile(
832 | join(testDir, "README.md"),
833 | "[Guide Section](./guide.md#setup)",
834 | );
835 | await writeFile(
836 | join(testDir, "guide.md"),
837 | "# Guide\n\n## Setup\n\nSetup instructions.",
838 | );
839 |
840 | const result = await checkDocumentationLinks({
841 | documentation_path: testDir,
842 | check_anchor_links: true,
843 | check_internal_links: true,
844 | check_external_links: false,
845 | });
846 |
847 | const formatted = formatMCPResponse(result);
848 | expect(formatted.isError).toBe(false);
849 | const contentText = formatted.content.map((c) => c.text).join(" ");
850 | expect(contentText).toContain('"totalLinks": 1');
851 | });
852 | });
853 |
854 | describe("Error Handling Edge Cases", () => {
855 | test("should handle internal link check errors gracefully", async () => {
856 | await writeFile(
857 | join(testDir, "README.md"),
858 | "[Broken](./nonexistent/deeply/nested/file.md)",
859 | );
860 |
861 | const result = await checkDocumentationLinks({
862 | documentation_path: testDir,
863 | check_internal_links: true,
864 | check_external_links: false,
865 | });
866 |
867 | const formatted = formatMCPResponse(result);
868 | expect(formatted.isError).toBe(false);
869 | const contentText = formatted.content.map((c) => c.text).join(" ");
870 | // Should report broken link
871 | expect(contentText).toContain('"brokenLinks": 1');
872 | });
873 |
874 | test("should handle network errors for external links", async () => {
875 | await writeFile(
876 | join(testDir, "README.md"),
877 | "[Invalid](https://this-domain-should-not-exist-12345.com)",
878 | );
879 |
880 | const result = await checkDocumentationLinks({
881 | documentation_path: testDir,
882 | check_external_links: true,
883 | timeout_ms: 2000,
884 | });
885 |
886 | const formatted = formatMCPResponse(result);
887 | expect(formatted.isError).toBe(false);
888 | // Should handle as broken or warning
889 | });
890 |
891 | test("should handle multiple link types in same document", async () => {
892 | await writeFile(
893 | join(testDir, "README.md"),
894 | `
895 | # Documentation
896 |
897 | [Internal](./guide.md)
898 | [External](https://github.com)
899 | [Email](mailto:[email protected])
900 | [Phone](tel:123-456-7890)
901 | [Anchor](./guide.md#section)
902 |
903 | ## Section
904 | Content here.
905 | `,
906 | );
907 | await writeFile(
908 | join(testDir, "guide.md"),
909 | "# Guide\n\n## Section\n\nContent",
910 | );
911 |
912 | const result = await checkDocumentationLinks({
913 | documentation_path: testDir,
914 | check_internal_links: true,
915 | check_external_links: true,
916 | check_anchor_links: true,
917 | });
918 |
919 | const formatted = formatMCPResponse(result);
920 | expect(formatted.isError).toBe(false);
921 | const contentText = formatted.content.map((c) => c.text).join(" ");
922 | // Should process checkable link types (internal, external, anchor to file)
923 | // mailto and tel are filtered out
924 | expect(contentText).toContain('"totalLinks": 3');
925 | expect(contentText).toContain("./guide.md");
926 | expect(contentText).toContain("https://github.com");
927 | });
928 | });
929 | });
930 |
```
--------------------------------------------------------------------------------
/src/utils/ast-analyzer.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * AST-based Code Analyzer (Phase 3)
3 | *
4 | * Uses tree-sitter parsers for multi-language AST analysis
5 | * Provides deep code structure extraction for drift detection
6 | */
7 |
8 | import { parse as parseTypeScript } from "@typescript-eslint/typescript-estree";
9 | import { promises as fs } from "fs";
10 | import path from "path";
11 | import crypto from "crypto";
12 |
13 | // Language configuration
14 | const LANGUAGE_CONFIGS: Record<
15 | string,
16 | { parser: string; extensions: string[] }
17 | > = {
18 | typescript: { parser: "tree-sitter-typescript", extensions: [".ts", ".tsx"] },
19 | javascript: {
20 | parser: "tree-sitter-javascript",
21 | extensions: [".js", ".jsx", ".mjs"],
22 | },
23 | python: { parser: "tree-sitter-python", extensions: [".py"] },
24 | rust: { parser: "tree-sitter-rust", extensions: [".rs"] },
25 | go: { parser: "tree-sitter-go", extensions: [".go"] },
26 | java: { parser: "tree-sitter-java", extensions: [".java"] },
27 | ruby: { parser: "tree-sitter-ruby", extensions: [".rb"] },
28 | bash: { parser: "tree-sitter-bash", extensions: [".sh", ".bash"] },
29 | };
30 |
31 | export interface FunctionSignature {
32 | name: string;
33 | parameters: ParameterInfo[];
34 | returnType: string | null;
35 | isAsync: boolean;
36 | isExported: boolean;
37 | isPublic: boolean;
38 | docComment: string | null;
39 | startLine: number;
40 | endLine: number;
41 | complexity: number;
42 | dependencies: string[];
43 | }
44 |
45 | export interface ParameterInfo {
46 | name: string;
47 | type: string | null;
48 | optional: boolean;
49 | defaultValue: string | null;
50 | }
51 |
52 | export interface ClassInfo {
53 | name: string;
54 | isExported: boolean;
55 | extends: string | null;
56 | implements: string[];
57 | methods: FunctionSignature[];
58 | properties: PropertyInfo[];
59 | docComment: string | null;
60 | startLine: number;
61 | endLine: number;
62 | }
63 |
64 | export interface PropertyInfo {
65 | name: string;
66 | type: string | null;
67 | isStatic: boolean;
68 | isReadonly: boolean;
69 | visibility: "public" | "private" | "protected";
70 | }
71 |
72 | export interface InterfaceInfo {
73 | name: string;
74 | isExported: boolean;
75 | extends: string[];
76 | properties: PropertyInfo[];
77 | methods: FunctionSignature[];
78 | docComment: string | null;
79 | startLine: number;
80 | endLine: number;
81 | }
82 |
83 | export interface TypeInfo {
84 | name: string;
85 | isExported: boolean;
86 | definition: string;
87 | docComment: string | null;
88 | startLine: number;
89 | endLine: number;
90 | }
91 |
92 | export interface ImportInfo {
93 | source: string;
94 | imports: Array<{ name: string; alias?: string }>;
95 | isDefault: boolean;
96 | startLine: number;
97 | }
98 |
99 | export interface ASTAnalysisResult {
100 | filePath: string;
101 | language: string;
102 | functions: FunctionSignature[];
103 | classes: ClassInfo[];
104 | interfaces: InterfaceInfo[];
105 | types: TypeInfo[];
106 | imports: ImportInfo[];
107 | exports: string[];
108 | contentHash: string;
109 | lastModified: string;
110 | linesOfCode: number;
111 | complexity: number;
112 | }
113 |
114 | export interface CodeDiff {
115 | type: "added" | "removed" | "modified" | "unchanged";
116 | category: "function" | "class" | "interface" | "type" | "import" | "export";
117 | name: string;
118 | details: string;
119 | oldSignature?: string;
120 | newSignature?: string;
121 | impactLevel: "breaking" | "major" | "minor" | "patch";
122 | }
123 |
124 | /**
125 | * Main AST Analyzer class
126 | */
127 | export class ASTAnalyzer {
128 | private parsers: Map<string, any> = new Map();
129 | private initialized = false;
130 |
131 | /**
132 | * Initialize tree-sitter parsers for all languages
133 | */
134 | async initialize(): Promise<void> {
135 | if (this.initialized) return;
136 |
137 | // Note: Tree-sitter initialization would happen here in a full implementation
138 | // For now, we're primarily using TypeScript/JavaScript parser
139 | // console.log(
140 | // "AST Analyzer initialized with language support:",
141 | // Object.keys(LANGUAGE_CONFIGS),
142 | // );
143 | this.initialized = true;
144 | }
145 |
146 | /**
147 | * Analyze a single file and extract AST information
148 | */
149 | async analyzeFile(filePath: string): Promise<ASTAnalysisResult | null> {
150 | if (!this.initialized) {
151 | await this.initialize();
152 | }
153 |
154 | const ext = path.extname(filePath);
155 | const language = this.detectLanguage(ext);
156 |
157 | if (!language) {
158 | console.warn(`Unsupported file extension: ${ext}`);
159 | return null;
160 | }
161 |
162 | const content = await fs.readFile(filePath, "utf-8");
163 | const stats = await fs.stat(filePath);
164 |
165 | // Use TypeScript parser for .ts/.tsx files
166 | if (language === "typescript" || language === "javascript") {
167 | return this.analyzeTypeScript(
168 | filePath,
169 | content,
170 | stats.mtime.toISOString(),
171 | );
172 | }
173 |
174 | // For other languages, use tree-sitter (placeholder)
175 | return this.analyzeWithTreeSitter(
176 | filePath,
177 | content,
178 | language,
179 | stats.mtime.toISOString(),
180 | );
181 | }
182 |
183 | /**
184 | * Analyze TypeScript/JavaScript using typescript-estree
185 | */
186 | private async analyzeTypeScript(
187 | filePath: string,
188 | content: string,
189 | lastModified: string,
190 | ): Promise<ASTAnalysisResult> {
191 | const functions: FunctionSignature[] = [];
192 | const classes: ClassInfo[] = [];
193 | const interfaces: InterfaceInfo[] = [];
194 | const types: TypeInfo[] = [];
195 | const imports: ImportInfo[] = [];
196 | const exports: string[] = [];
197 |
198 | try {
199 | const ast = parseTypeScript(content, {
200 | loc: true,
201 | range: true,
202 | tokens: false,
203 | comment: true,
204 | jsx: filePath.endsWith(".tsx") || filePath.endsWith(".jsx"),
205 | });
206 |
207 | // Extract functions
208 | this.extractFunctions(ast, content, functions);
209 |
210 | // Extract classes
211 | this.extractClasses(ast, content, classes);
212 |
213 | // Extract interfaces
214 | this.extractInterfaces(ast, content, interfaces);
215 |
216 | // Extract type aliases
217 | this.extractTypes(ast, content, types);
218 |
219 | // Extract imports
220 | this.extractImports(ast, imports);
221 |
222 | // Extract exports
223 | this.extractExports(ast, exports);
224 | } catch (error) {
225 | console.warn(`Failed to parse TypeScript file ${filePath}:`, error);
226 | }
227 |
228 | const contentHash = crypto
229 | .createHash("sha256")
230 | .update(content)
231 | .digest("hex");
232 | const linesOfCode = content.split("\n").length;
233 | const complexity = this.calculateComplexity(functions, classes);
234 |
235 | return {
236 | filePath,
237 | language:
238 | filePath.endsWith(".ts") || filePath.endsWith(".tsx")
239 | ? "typescript"
240 | : "javascript",
241 | functions,
242 | classes,
243 | interfaces,
244 | types,
245 | imports,
246 | exports,
247 | contentHash,
248 | lastModified,
249 | linesOfCode,
250 | complexity,
251 | };
252 | }
253 |
254 | /**
255 | * Analyze using tree-sitter (placeholder for other languages)
256 | */
257 | private async analyzeWithTreeSitter(
258 | filePath: string,
259 | content: string,
260 | language: string,
261 | lastModified: string,
262 | ): Promise<ASTAnalysisResult> {
263 | // Placeholder for tree-sitter analysis
264 | // In a full implementation, we'd parse the content using tree-sitter
265 | // and extract language-specific constructs
266 |
267 | const contentHash = crypto
268 | .createHash("sha256")
269 | .update(content)
270 | .digest("hex");
271 | const linesOfCode = content.split("\n").length;
272 |
273 | return {
274 | filePath,
275 | language,
276 | functions: [],
277 | classes: [],
278 | interfaces: [],
279 | types: [],
280 | imports: [],
281 | exports: [],
282 | contentHash,
283 | lastModified,
284 | linesOfCode,
285 | complexity: 0,
286 | };
287 | }
288 |
289 | /**
290 | * Extract function declarations from AST
291 | */
292 | private extractFunctions(
293 | ast: any,
294 | content: string,
295 | functions: FunctionSignature[],
296 | ): void {
297 | const lines = content.split("\n");
298 |
299 | const traverse = (node: any, isExported = false) => {
300 | if (!node) return;
301 |
302 | // Handle export declarations
303 | if (
304 | node.type === "ExportNamedDeclaration" ||
305 | node.type === "ExportDefaultDeclaration"
306 | ) {
307 | if (node.declaration) {
308 | traverse(node.declaration, true);
309 | }
310 | return;
311 | }
312 |
313 | // Function declarations
314 | if (node.type === "FunctionDeclaration") {
315 | const func = this.parseFunctionNode(node, lines, isExported);
316 | if (func) functions.push(func);
317 | }
318 |
319 | // Arrow functions assigned to variables
320 | if (node.type === "VariableDeclaration") {
321 | for (const declarator of node.declarations || []) {
322 | if (declarator.init?.type === "ArrowFunctionExpression") {
323 | const func = this.parseArrowFunction(declarator, lines, isExported);
324 | if (func) functions.push(func);
325 | }
326 | }
327 | }
328 |
329 | // Traverse children
330 | for (const key in node) {
331 | if (typeof node[key] === "object" && node[key] !== null) {
332 | if (Array.isArray(node[key])) {
333 | node[key].forEach((child: any) => traverse(child, false));
334 | } else {
335 | traverse(node[key], false);
336 | }
337 | }
338 | }
339 | };
340 |
341 | traverse(ast);
342 | }
343 |
344 | /**
345 | * Parse function node
346 | */
347 | private parseFunctionNode(
348 | node: any,
349 | lines: string[],
350 | isExported: boolean,
351 | ): FunctionSignature | null {
352 | if (!node.id?.name) return null;
353 |
354 | const docComment = this.extractDocComment(node.loc?.start.line - 1, lines);
355 | const parameters = this.extractParameters(node.params);
356 |
357 | return {
358 | name: node.id.name,
359 | parameters,
360 | returnType: this.extractReturnType(node),
361 | isAsync: node.async || false,
362 | isExported,
363 | isPublic: true,
364 | docComment,
365 | startLine: node.loc?.start.line || 0,
366 | endLine: node.loc?.end.line || 0,
367 | complexity: this.calculateFunctionComplexity(node),
368 | dependencies: [],
369 | };
370 | }
371 |
372 | /**
373 | * Parse arrow function
374 | */
375 | private parseArrowFunction(
376 | declarator: any,
377 | lines: string[],
378 | isExported: boolean,
379 | ): FunctionSignature | null {
380 | if (!declarator.id?.name) return null;
381 |
382 | const node = declarator.init;
383 | const docComment = this.extractDocComment(
384 | declarator.loc?.start.line - 1,
385 | lines,
386 | );
387 | const parameters = this.extractParameters(node.params);
388 |
389 | return {
390 | name: declarator.id.name,
391 | parameters,
392 | returnType: this.extractReturnType(node),
393 | isAsync: node.async || false,
394 | isExported,
395 | isPublic: true,
396 | docComment,
397 | startLine: declarator.loc?.start.line || 0,
398 | endLine: declarator.loc?.end.line || 0,
399 | complexity: this.calculateFunctionComplexity(node),
400 | dependencies: [],
401 | };
402 | }
403 |
404 | /**
405 | * Extract classes from AST
406 | */
407 | private extractClasses(
408 | ast: any,
409 | content: string,
410 | classes: ClassInfo[],
411 | ): void {
412 | const lines = content.split("\n");
413 |
414 | const traverse = (node: any, isExported = false) => {
415 | if (!node) return;
416 |
417 | // Handle export declarations
418 | if (
419 | node.type === "ExportNamedDeclaration" ||
420 | node.type === "ExportDefaultDeclaration"
421 | ) {
422 | if (node.declaration) {
423 | traverse(node.declaration, true);
424 | }
425 | return;
426 | }
427 |
428 | if (node.type === "ClassDeclaration" && node.id?.name) {
429 | const classInfo = this.parseClassNode(node, lines, isExported);
430 | if (classInfo) classes.push(classInfo);
431 | }
432 |
433 | for (const key in node) {
434 | if (typeof node[key] === "object" && node[key] !== null) {
435 | if (Array.isArray(node[key])) {
436 | node[key].forEach((child: any) => traverse(child, false));
437 | } else {
438 | traverse(node[key], false);
439 | }
440 | }
441 | }
442 | };
443 |
444 | traverse(ast);
445 | }
446 |
447 | /**
448 | * Parse class node
449 | */
450 | private parseClassNode(
451 | node: any,
452 | lines: string[],
453 | isExported: boolean,
454 | ): ClassInfo | null {
455 | const methods: FunctionSignature[] = [];
456 | const properties: PropertyInfo[] = [];
457 |
458 | // Extract methods and properties
459 | if (node.body?.body) {
460 | for (const member of node.body.body) {
461 | if (member.type === "MethodDefinition") {
462 | const method = this.parseMethodNode(member, lines);
463 | if (method) methods.push(method);
464 | } else if (member.type === "PropertyDefinition") {
465 | const property = this.parsePropertyNode(member);
466 | if (property) properties.push(property);
467 | }
468 | }
469 | }
470 |
471 | return {
472 | name: node.id.name,
473 | isExported,
474 | extends: node.superClass?.name || null,
475 | implements:
476 | node.implements?.map((i: any) => i.expression?.name || "unknown") || [],
477 | methods,
478 | properties,
479 | docComment: this.extractDocComment(node.loc?.start.line - 1, lines),
480 | startLine: node.loc?.start.line || 0,
481 | endLine: node.loc?.end.line || 0,
482 | };
483 | }
484 |
485 | /**
486 | * Parse method node
487 | */
488 | private parseMethodNode(
489 | node: any,
490 | lines: string[],
491 | ): FunctionSignature | null {
492 | if (!node.key?.name) return null;
493 |
494 | return {
495 | name: node.key.name,
496 | parameters: this.extractParameters(node.value?.params || []),
497 | returnType: this.extractReturnType(node.value),
498 | isAsync: node.value?.async || false,
499 | isExported: false,
500 | isPublic: !node.key.name.startsWith("_"),
501 | docComment: this.extractDocComment(node.loc?.start.line - 1, lines),
502 | startLine: node.loc?.start.line || 0,
503 | endLine: node.loc?.end.line || 0,
504 | complexity: this.calculateFunctionComplexity(node.value),
505 | dependencies: [],
506 | };
507 | }
508 |
509 | /**
510 | * Parse property node
511 | */
512 | private parsePropertyNode(node: any): PropertyInfo | null {
513 | if (!node.key?.name) return null;
514 |
515 | return {
516 | name: node.key.name,
517 | type: this.extractTypeAnnotation(node.typeAnnotation),
518 | isStatic: node.static || false,
519 | isReadonly: node.readonly || false,
520 | visibility: this.determineVisibility(node),
521 | };
522 | }
523 |
524 | /**
525 | * Extract interfaces from AST
526 | */
527 | private extractInterfaces(
528 | ast: any,
529 | content: string,
530 | interfaces: InterfaceInfo[],
531 | ): void {
532 | const lines = content.split("\n");
533 |
534 | const traverse = (node: any, isExported = false) => {
535 | if (!node) return;
536 |
537 | // Handle export declarations
538 | if (
539 | node.type === "ExportNamedDeclaration" ||
540 | node.type === "ExportDefaultDeclaration"
541 | ) {
542 | if (node.declaration) {
543 | traverse(node.declaration, true);
544 | }
545 | return;
546 | }
547 |
548 | if (node.type === "TSInterfaceDeclaration" && node.id?.name) {
549 | const interfaceInfo = this.parseInterfaceNode(node, lines, isExported);
550 | if (interfaceInfo) interfaces.push(interfaceInfo);
551 | }
552 |
553 | for (const key in node) {
554 | if (typeof node[key] === "object" && node[key] !== null) {
555 | if (Array.isArray(node[key])) {
556 | node[key].forEach((child: any) => traverse(child, false));
557 | } else {
558 | traverse(node[key], false);
559 | }
560 | }
561 | }
562 | };
563 |
564 | traverse(ast);
565 | }
566 |
567 | /**
568 | * Parse interface node
569 | */
570 | private parseInterfaceNode(
571 | node: any,
572 | lines: string[],
573 | isExported: boolean,
574 | ): InterfaceInfo | null {
575 | const properties: PropertyInfo[] = [];
576 | const methods: FunctionSignature[] = [];
577 |
578 | if (node.body?.body) {
579 | for (const member of node.body.body) {
580 | if (member.type === "TSPropertySignature") {
581 | const prop = this.parseInterfaceProperty(member);
582 | if (prop) properties.push(prop);
583 | } else if (member.type === "TSMethodSignature") {
584 | const method = this.parseInterfaceMethod(member);
585 | if (method) methods.push(method);
586 | }
587 | }
588 | }
589 |
590 | return {
591 | name: node.id.name,
592 | isExported,
593 | extends:
594 | node.extends?.map((e: any) => e.expression?.name || "unknown") || [],
595 | properties,
596 | methods,
597 | docComment: this.extractDocComment(node.loc?.start.line - 1, lines),
598 | startLine: node.loc?.start.line || 0,
599 | endLine: node.loc?.end.line || 0,
600 | };
601 | }
602 |
603 | /**
604 | * Parse interface property
605 | */
606 | private parseInterfaceProperty(node: any): PropertyInfo | null {
607 | if (!node.key?.name) return null;
608 |
609 | return {
610 | name: node.key.name,
611 | type: this.extractTypeAnnotation(node.typeAnnotation),
612 | isStatic: false,
613 | isReadonly: node.readonly || false,
614 | visibility: "public",
615 | };
616 | }
617 |
618 | /**
619 | * Parse interface method
620 | */
621 | private parseInterfaceMethod(node: any): FunctionSignature | null {
622 | if (!node.key?.name) return null;
623 |
624 | return {
625 | name: node.key.name,
626 | parameters: this.extractParameters(node.params || []),
627 | returnType: this.extractTypeAnnotation(node.returnType),
628 | isAsync: false,
629 | isExported: false,
630 | isPublic: true,
631 | docComment: null,
632 | startLine: node.loc?.start.line || 0,
633 | endLine: node.loc?.end.line || 0,
634 | complexity: 0,
635 | dependencies: [],
636 | };
637 | }
638 |
639 | /**
640 | * Extract type aliases from AST
641 | */
642 | private extractTypes(ast: any, content: string, types: TypeInfo[]): void {
643 | const lines = content.split("\n");
644 |
645 | const traverse = (node: any, isExported = false) => {
646 | if (!node) return;
647 |
648 | // Handle export declarations
649 | if (
650 | node.type === "ExportNamedDeclaration" ||
651 | node.type === "ExportDefaultDeclaration"
652 | ) {
653 | if (node.declaration) {
654 | traverse(node.declaration, true);
655 | }
656 | return;
657 | }
658 |
659 | if (node.type === "TSTypeAliasDeclaration" && node.id?.name) {
660 | const typeInfo = this.parseTypeNode(node, lines, isExported);
661 | if (typeInfo) types.push(typeInfo);
662 | }
663 |
664 | for (const key in node) {
665 | if (typeof node[key] === "object" && node[key] !== null) {
666 | if (Array.isArray(node[key])) {
667 | node[key].forEach((child: any) => traverse(child, false));
668 | } else {
669 | traverse(node[key], false);
670 | }
671 | }
672 | }
673 | };
674 |
675 | traverse(ast);
676 | }
677 |
678 | /**
679 | * Parse type alias node
680 | */
681 | private parseTypeNode(
682 | node: any,
683 | lines: string[],
684 | isExported: boolean,
685 | ): TypeInfo | null {
686 | return {
687 | name: node.id.name,
688 | isExported,
689 | definition: this.extractTypeDefinition(node.typeAnnotation),
690 | docComment: this.extractDocComment(node.loc?.start.line - 1, lines),
691 | startLine: node.loc?.start.line || 0,
692 | endLine: node.loc?.end.line || 0,
693 | };
694 | }
695 |
696 | /**
697 | * Extract imports from AST
698 | */
699 | private extractImports(ast: any, imports: ImportInfo[]): void {
700 | const traverse = (node: any) => {
701 | if (!node) return;
702 |
703 | if (node.type === "ImportDeclaration") {
704 | const importInfo: ImportInfo = {
705 | source: node.source?.value || "",
706 | imports: [],
707 | isDefault: false,
708 | startLine: node.loc?.start.line || 0,
709 | };
710 |
711 | for (const specifier of node.specifiers || []) {
712 | if (specifier.type === "ImportDefaultSpecifier") {
713 | importInfo.isDefault = true;
714 | importInfo.imports.push({
715 | name: specifier.local?.name || "default",
716 | });
717 | } else if (specifier.type === "ImportSpecifier") {
718 | importInfo.imports.push({
719 | name: specifier.imported?.name || "",
720 | alias:
721 | specifier.local?.name !== specifier.imported?.name
722 | ? specifier.local?.name
723 | : undefined,
724 | });
725 | }
726 | }
727 |
728 | imports.push(importInfo);
729 | }
730 |
731 | for (const key in node) {
732 | if (typeof node[key] === "object" && node[key] !== null) {
733 | if (Array.isArray(node[key])) {
734 | node[key].forEach((child: any) => traverse(child));
735 | } else {
736 | traverse(node[key]);
737 | }
738 | }
739 | }
740 | };
741 |
742 | traverse(ast);
743 | }
744 |
745 | /**
746 | * Extract exports from AST
747 | */
748 | private extractExports(ast: any, exports: string[]): void {
749 | const traverse = (node: any) => {
750 | if (!node) return;
751 |
752 | // Named exports
753 | if (node.type === "ExportNamedDeclaration") {
754 | if (node.declaration) {
755 | if (node.declaration.id?.name) {
756 | exports.push(node.declaration.id.name);
757 | } else if (node.declaration.declarations) {
758 | for (const decl of node.declaration.declarations) {
759 | if (decl.id?.name) exports.push(decl.id.name);
760 | }
761 | }
762 | }
763 | for (const specifier of node.specifiers || []) {
764 | if (specifier.exported?.name) exports.push(specifier.exported.name);
765 | }
766 | }
767 |
768 | // Default export
769 | if (node.type === "ExportDefaultDeclaration") {
770 | if (node.declaration?.id?.name) {
771 | exports.push(node.declaration.id.name);
772 | } else {
773 | exports.push("default");
774 | }
775 | }
776 |
777 | for (const key in node) {
778 | if (typeof node[key] === "object" && node[key] !== null) {
779 | if (Array.isArray(node[key])) {
780 | node[key].forEach((child: any) => traverse(child));
781 | } else {
782 | traverse(node[key]);
783 | }
784 | }
785 | }
786 | };
787 |
788 | traverse(ast);
789 | }
790 |
791 | // Helper methods
792 |
793 | private extractParameters(params: any[]): ParameterInfo[] {
794 | return params.map((param) => ({
795 | name: param.name || param.argument?.name || param.left?.name || "unknown",
796 | type: this.extractTypeAnnotation(param.typeAnnotation),
797 | optional: param.optional || false,
798 | defaultValue: param.right ? this.extractDefaultValue(param.right) : null,
799 | }));
800 | }
801 |
802 | private extractReturnType(node: any): string | null {
803 | return this.extractTypeAnnotation(node?.returnType);
804 | }
805 |
806 | private extractTypeAnnotation(typeAnnotation: any): string | null {
807 | if (!typeAnnotation) return null;
808 | if (typeAnnotation.typeAnnotation)
809 | return this.extractTypeDefinition(typeAnnotation.typeAnnotation);
810 | return this.extractTypeDefinition(typeAnnotation);
811 | }
812 |
813 | private extractTypeDefinition(typeNode: any): string {
814 | if (!typeNode) return "unknown";
815 | if (typeNode.type === "TSStringKeyword") return "string";
816 | if (typeNode.type === "TSNumberKeyword") return "number";
817 | if (typeNode.type === "TSBooleanKeyword") return "boolean";
818 | if (typeNode.type === "TSAnyKeyword") return "any";
819 | if (typeNode.type === "TSVoidKeyword") return "void";
820 | if (typeNode.type === "TSTypeReference")
821 | return typeNode.typeName?.name || "unknown";
822 | return "unknown";
823 | }
824 |
825 | private extractDefaultValue(node: any): string | null {
826 | if (node.type === "Literal") return String(node.value);
827 | if (node.type === "Identifier") return node.name;
828 | return null;
829 | }
830 |
831 | private extractDocComment(
832 | lineNumber: number,
833 | lines: string[],
834 | ): string | null {
835 | if (lineNumber < 0 || lineNumber >= lines.length) return null;
836 |
837 | const comment: string[] = [];
838 | let currentLine = lineNumber;
839 |
840 | // Look backwards for JSDoc comment
841 | while (currentLine >= 0) {
842 | const line = lines[currentLine].trim();
843 | if (line.startsWith("*/")) {
844 | comment.unshift(line);
845 | currentLine--;
846 | continue;
847 | }
848 | if (line.startsWith("*") || line.startsWith("/**")) {
849 | comment.unshift(line);
850 | if (line.startsWith("/**")) break;
851 | currentLine--;
852 | continue;
853 | }
854 | if (comment.length > 0) break;
855 | currentLine--;
856 | }
857 |
858 | return comment.length > 0 ? comment.join("\n") : null;
859 | }
860 |
861 | private isExported(node: any): boolean {
862 | if (!node) return false;
863 |
864 | // Check parent for export
865 | let current = node;
866 | while (current) {
867 | if (
868 | current.type === "ExportNamedDeclaration" ||
869 | current.type === "ExportDefaultDeclaration"
870 | ) {
871 | return true;
872 | }
873 | current = current.parent;
874 | }
875 |
876 | return false;
877 | }
878 |
879 | private determineVisibility(node: any): "public" | "private" | "protected" {
880 | if (node.accessibility) return node.accessibility;
881 | if (node.key?.name?.startsWith("_")) return "private";
882 | if (node.key?.name?.startsWith("#")) return "private";
883 | return "public";
884 | }
885 |
886 | private calculateFunctionComplexity(node: any): number {
887 | // Simplified cyclomatic complexity
888 | let complexity = 1;
889 |
890 | const traverse = (n: any) => {
891 | if (!n) return;
892 |
893 | // Increment for control flow statements
894 | if (
895 | [
896 | "IfStatement",
897 | "ConditionalExpression",
898 | "ForStatement",
899 | "WhileStatement",
900 | "DoWhileStatement",
901 | "SwitchCase",
902 | "CatchClause",
903 | ].includes(n.type)
904 | ) {
905 | complexity++;
906 | }
907 |
908 | for (const key in n) {
909 | if (typeof n[key] === "object" && n[key] !== null) {
910 | if (Array.isArray(n[key])) {
911 | n[key].forEach((child: any) => traverse(child));
912 | } else {
913 | traverse(n[key]);
914 | }
915 | }
916 | }
917 | };
918 |
919 | traverse(node);
920 | return complexity;
921 | }
922 |
923 | private calculateComplexity(
924 | functions: FunctionSignature[],
925 | classes: ClassInfo[],
926 | ): number {
927 | const functionComplexity = functions.reduce(
928 | (sum, f) => sum + f.complexity,
929 | 0,
930 | );
931 | const classComplexity = classes.reduce(
932 | (sum, c) =>
933 | sum + c.methods.reduce((methodSum, m) => methodSum + m.complexity, 0),
934 | 0,
935 | );
936 | return functionComplexity + classComplexity;
937 | }
938 |
939 | private detectLanguage(ext: string): string | null {
940 | for (const [lang, config] of Object.entries(LANGUAGE_CONFIGS)) {
941 | if (config.extensions.includes(ext)) return lang;
942 | }
943 | return null;
944 | }
945 |
946 | /**
947 | * Compare two AST analysis results and detect changes
948 | */
949 | async detectDrift(
950 | oldAnalysis: ASTAnalysisResult,
951 | newAnalysis: ASTAnalysisResult,
952 | ): Promise<CodeDiff[]> {
953 | const diffs: CodeDiff[] = [];
954 |
955 | // Compare functions
956 | diffs.push(
957 | ...this.compareFunctions(oldAnalysis.functions, newAnalysis.functions),
958 | );
959 |
960 | // Compare classes
961 | diffs.push(
962 | ...this.compareClasses(oldAnalysis.classes, newAnalysis.classes),
963 | );
964 |
965 | // Compare interfaces
966 | diffs.push(
967 | ...this.compareInterfaces(oldAnalysis.interfaces, newAnalysis.interfaces),
968 | );
969 |
970 | // Compare types
971 | diffs.push(...this.compareTypes(oldAnalysis.types, newAnalysis.types));
972 |
973 | return diffs;
974 | }
975 |
976 | private compareFunctions(
977 | oldFuncs: FunctionSignature[],
978 | newFuncs: FunctionSignature[],
979 | ): CodeDiff[] {
980 | const diffs: CodeDiff[] = [];
981 | const oldMap = new Map(oldFuncs.map((f) => [f.name, f]));
982 | const newMap = new Map(newFuncs.map((f) => [f.name, f]));
983 |
984 | // Check for removed functions
985 | for (const [name, func] of oldMap) {
986 | if (!newMap.has(name)) {
987 | diffs.push({
988 | type: "removed",
989 | category: "function",
990 | name,
991 | details: `Function '${name}' was removed`,
992 | oldSignature: this.formatFunctionSignature(func),
993 | impactLevel: func.isExported ? "breaking" : "minor",
994 | });
995 | }
996 | }
997 |
998 | // Check for added functions
999 | for (const [name, func] of newMap) {
1000 | if (!oldMap.has(name)) {
1001 | diffs.push({
1002 | type: "added",
1003 | category: "function",
1004 | name,
1005 | details: `Function '${name}' was added`,
1006 | newSignature: this.formatFunctionSignature(func),
1007 | impactLevel: "patch",
1008 | });
1009 | }
1010 | }
1011 |
1012 | // Check for modified functions
1013 | for (const [name, newFunc] of newMap) {
1014 | const oldFunc = oldMap.get(name);
1015 | if (oldFunc) {
1016 | const changes = this.detectFunctionChanges(oldFunc, newFunc);
1017 | if (changes.length > 0) {
1018 | diffs.push({
1019 | type: "modified",
1020 | category: "function",
1021 | name,
1022 | details: changes.join("; "),
1023 | oldSignature: this.formatFunctionSignature(oldFunc),
1024 | newSignature: this.formatFunctionSignature(newFunc),
1025 | impactLevel: this.determineFunctionImpact(oldFunc, newFunc),
1026 | });
1027 | }
1028 | }
1029 | }
1030 |
1031 | return diffs;
1032 | }
1033 |
1034 | private compareClasses(
1035 | oldClasses: ClassInfo[],
1036 | newClasses: ClassInfo[],
1037 | ): CodeDiff[] {
1038 | const diffs: CodeDiff[] = [];
1039 | const oldMap = new Map(oldClasses.map((c) => [c.name, c]));
1040 | const newMap = new Map(newClasses.map((c) => [c.name, c]));
1041 |
1042 | for (const [name, oldClass] of oldMap) {
1043 | if (!newMap.has(name)) {
1044 | diffs.push({
1045 | type: "removed",
1046 | category: "class",
1047 | name,
1048 | details: `Class '${name}' was removed`,
1049 | impactLevel: oldClass.isExported ? "breaking" : "minor",
1050 | });
1051 | }
1052 | }
1053 |
1054 | for (const [name] of newMap) {
1055 | if (!oldMap.has(name)) {
1056 | diffs.push({
1057 | type: "added",
1058 | category: "class",
1059 | name,
1060 | details: `Class '${name}' was added`,
1061 | impactLevel: "patch",
1062 | });
1063 | }
1064 | }
1065 |
1066 | return diffs;
1067 | }
1068 |
1069 | private compareInterfaces(
1070 | oldInterfaces: InterfaceInfo[],
1071 | newInterfaces: InterfaceInfo[],
1072 | ): CodeDiff[] {
1073 | const diffs: CodeDiff[] = [];
1074 | const oldMap = new Map(oldInterfaces.map((i) => [i.name, i]));
1075 | const newMap = new Map(newInterfaces.map((i) => [i.name, i]));
1076 |
1077 | for (const [name, oldInterface] of oldMap) {
1078 | if (!newMap.has(name)) {
1079 | diffs.push({
1080 | type: "removed",
1081 | category: "interface",
1082 | name,
1083 | details: `Interface '${name}' was removed`,
1084 | impactLevel: oldInterface.isExported ? "breaking" : "minor",
1085 | });
1086 | }
1087 | }
1088 |
1089 | for (const [name] of newMap) {
1090 | if (!oldMap.has(name)) {
1091 | diffs.push({
1092 | type: "added",
1093 | category: "interface",
1094 | name,
1095 | details: `Interface '${name}' was added`,
1096 | impactLevel: "patch",
1097 | });
1098 | }
1099 | }
1100 |
1101 | return diffs;
1102 | }
1103 |
1104 | private compareTypes(oldTypes: TypeInfo[], newTypes: TypeInfo[]): CodeDiff[] {
1105 | const diffs: CodeDiff[] = [];
1106 | const oldMap = new Map(oldTypes.map((t) => [t.name, t]));
1107 | const newMap = new Map(newTypes.map((t) => [t.name, t]));
1108 |
1109 | for (const [name, oldType] of oldMap) {
1110 | if (!newMap.has(name)) {
1111 | diffs.push({
1112 | type: "removed",
1113 | category: "type",
1114 | name,
1115 | details: `Type '${name}' was removed`,
1116 | impactLevel: oldType.isExported ? "breaking" : "minor",
1117 | });
1118 | }
1119 | }
1120 |
1121 | for (const [name] of newMap) {
1122 | if (!oldMap.has(name)) {
1123 | diffs.push({
1124 | type: "added",
1125 | category: "type",
1126 | name,
1127 | details: `Type '${name}' was added`,
1128 | impactLevel: "patch",
1129 | });
1130 | }
1131 | }
1132 |
1133 | return diffs;
1134 | }
1135 |
1136 | private detectFunctionChanges(
1137 | oldFunc: FunctionSignature,
1138 | newFunc: FunctionSignature,
1139 | ): string[] {
1140 | const changes: string[] = [];
1141 |
1142 | // Check parameter changes
1143 | if (oldFunc.parameters.length !== newFunc.parameters.length) {
1144 | changes.push(
1145 | `Parameter count changed from ${oldFunc.parameters.length} to ${newFunc.parameters.length}`,
1146 | );
1147 | }
1148 |
1149 | // Check return type changes
1150 | if (oldFunc.returnType !== newFunc.returnType) {
1151 | changes.push(
1152 | `Return type changed from '${oldFunc.returnType}' to '${newFunc.returnType}'`,
1153 | );
1154 | }
1155 |
1156 | // Check async changes
1157 | if (oldFunc.isAsync !== newFunc.isAsync) {
1158 | changes.push(
1159 | newFunc.isAsync
1160 | ? "Function became async"
1161 | : "Function is no longer async",
1162 | );
1163 | }
1164 |
1165 | // Check export changes
1166 | if (oldFunc.isExported !== newFunc.isExported) {
1167 | changes.push(
1168 | newFunc.isExported
1169 | ? "Function is now exported"
1170 | : "Function is no longer exported",
1171 | );
1172 | }
1173 |
1174 | return changes;
1175 | }
1176 |
1177 | private determineFunctionImpact(
1178 | oldFunc: FunctionSignature,
1179 | newFunc: FunctionSignature,
1180 | ): "breaking" | "major" | "minor" | "patch" {
1181 | // Breaking changes
1182 | if (oldFunc.isExported) {
1183 | if (oldFunc.parameters.length !== newFunc.parameters.length)
1184 | return "breaking";
1185 | if (oldFunc.returnType !== newFunc.returnType) return "breaking";
1186 | // If a function was exported and is no longer exported, that's breaking
1187 | if (oldFunc.isExported && !newFunc.isExported) return "breaking";
1188 | }
1189 |
1190 | // Major changes
1191 | if (oldFunc.isAsync !== newFunc.isAsync) return "major";
1192 |
1193 | // Minor changes (new API surface)
1194 | // If a function becomes exported, that's a minor change (new feature/API)
1195 | if (!oldFunc.isExported && newFunc.isExported) return "minor";
1196 |
1197 | return "patch";
1198 | }
1199 |
1200 | private formatFunctionSignature(func: FunctionSignature): string {
1201 | const params = func.parameters
1202 | .map((p) => `${p.name}: ${p.type || "any"}`)
1203 | .join(", ");
1204 | const returnType = func.returnType || "void";
1205 | const asyncPrefix = func.isAsync ? "async " : "";
1206 | return `${asyncPrefix}${func.name}(${params}): ${returnType}`;
1207 | }
1208 | }
1209 |
```
--------------------------------------------------------------------------------
/src/memory/knowledge-graph.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Knowledge Graph Architecture for DocuMCP
3 | * Implements Phase 1.1: Enhanced Knowledge Graph Schema Implementation
4 | * Previously: Issue #48: Knowledge Graph Architecture
5 | *
6 | * Creates entity relationship graphs for projects, technologies, patterns, and dependencies
7 | * to enable advanced reasoning and recommendation improvements.
8 | *
9 | * Enhanced with comprehensive entity types and relationship schemas following NEW_PRD.md
10 | */
11 |
12 | import { MemoryManager } from "./manager.js";
13 | import { MemoryEntry } from "./storage.js";
14 | import {
15 | validateEntity,
16 | validateRelationship,
17 | SCHEMA_METADATA,
18 | } from "./schemas.js";
19 |
20 | export interface GraphNode {
21 | id: string;
22 | type:
23 | | "project"
24 | | "technology"
25 | | "pattern"
26 | | "user"
27 | | "outcome"
28 | | "recommendation"
29 | | "configuration"
30 | | "documentation"
31 | | "code_file"
32 | | "documentation_section"
33 | | "link_validation"
34 | | "sync_event"
35 | | "documentation_freshness_event";
36 | label: string;
37 | properties: Record<string, any>;
38 | weight: number;
39 | lastUpdated: string;
40 | }
41 |
42 | export interface GraphEdge {
43 | id: string;
44 | source: string;
45 | target: string;
46 | type:
47 | | "uses"
48 | | "similar_to"
49 | | "depends_on"
50 | | "recommends"
51 | | "results_in"
52 | | "created_by"
53 | | "project_uses_technology"
54 | | "user_prefers_ssg"
55 | | "project_deployed_with"
56 | | "documents"
57 | | "references"
58 | | "outdated_for"
59 | | "has_link_validation"
60 | | "requires_fix"
61 | | "project_has_freshness_event"
62 | | (string & NonNullable<unknown>); // Allow any string (for timestamped types like "project_deployed_with:2024-...")
63 | weight: number;
64 | properties: Record<string, any>;
65 | confidence: number;
66 | lastUpdated: string;
67 | }
68 |
69 | export interface GraphPath {
70 | nodes: GraphNode[];
71 | edges: GraphEdge[];
72 | totalWeight: number;
73 | confidence: number;
74 | }
75 |
76 | export interface GraphQuery {
77 | nodeTypes?: string[];
78 | edgeTypes?: string[];
79 | properties?: Record<string, any>;
80 | minWeight?: number;
81 | maxDepth?: number;
82 | startNode?: string;
83 | }
84 |
85 | export interface RecommendationPath {
86 | from: GraphNode;
87 | to: GraphNode;
88 | path: GraphPath;
89 | reasoning: string[];
90 | confidence: number;
91 | }
92 |
93 | export class KnowledgeGraph {
94 | private nodes: Map<string, GraphNode>;
95 | private edges: Map<string, GraphEdge>;
96 | private adjacencyList: Map<string, Set<string>>;
97 | private memoryManager: MemoryManager;
98 | private lastUpdate: string;
99 |
100 | constructor(memoryManager: MemoryManager) {
101 | this.nodes = new Map();
102 | this.edges = new Map();
103 | this.adjacencyList = new Map();
104 | this.memoryManager = memoryManager;
105 | this.lastUpdate = new Date().toISOString();
106 | }
107 |
108 | async initialize(): Promise<void> {
109 | await this.loadFromMemory();
110 | await this.buildFromMemories();
111 | }
112 |
113 | /**
114 | * Add or update a node in the knowledge graph
115 | */
116 | addNode(node: Omit<GraphNode, "lastUpdated">): GraphNode {
117 | const fullNode: GraphNode = {
118 | ...node,
119 | lastUpdated: new Date().toISOString(),
120 | };
121 |
122 | this.nodes.set(node.id, fullNode);
123 |
124 | if (!this.adjacencyList.has(node.id)) {
125 | this.adjacencyList.set(node.id, new Set());
126 | }
127 |
128 | return fullNode;
129 | }
130 |
131 | /**
132 | * Add or update an edge in the knowledge graph
133 | */
134 | addEdge(edge: Omit<GraphEdge, "id" | "lastUpdated">): GraphEdge {
135 | const edgeId = `${edge.source}-${edge.type}-${edge.target}`;
136 | const fullEdge: GraphEdge = {
137 | ...edge,
138 | id: edgeId,
139 | lastUpdated: new Date().toISOString(),
140 | };
141 |
142 | this.edges.set(edgeId, fullEdge);
143 |
144 | // Update adjacency list
145 | if (!this.adjacencyList.has(edge.source)) {
146 | this.adjacencyList.set(edge.source, new Set());
147 | }
148 | if (!this.adjacencyList.has(edge.target)) {
149 | this.adjacencyList.set(edge.target, new Set());
150 | }
151 |
152 | this.adjacencyList.get(edge.source)!.add(edge.target);
153 |
154 | return fullEdge;
155 | }
156 |
157 | /**
158 | * Build knowledge graph from memory entries
159 | */
160 | async buildFromMemories(): Promise<void> {
161 | const memories = await this.memoryManager.search("", {
162 | sortBy: "timestamp",
163 | });
164 |
165 | for (const memory of memories) {
166 | await this.processMemoryEntry(memory);
167 | }
168 |
169 | await this.computeRelationships();
170 | await this.updateWeights();
171 | }
172 |
173 | /**
174 | * Process a single memory entry to extract graph entities
175 | */
176 | private async processMemoryEntry(memory: MemoryEntry): Promise<void> {
177 | // Create project node
178 | if (memory.metadata.projectId) {
179 | const projectNode = this.addNode({
180 | id: `project:${memory.metadata.projectId}`,
181 | type: "project",
182 | label: memory.metadata.projectId,
183 | properties: {
184 | repository: memory.metadata.repository,
185 | lastActivity: memory.timestamp,
186 | },
187 | weight: 1.0,
188 | });
189 |
190 | // Create technology nodes
191 | if (memory.type === "analysis" && memory.data.language) {
192 | const langNode = this.addNode({
193 | id: `tech:${memory.data.language.primary}`,
194 | type: "technology",
195 | label: memory.data.language.primary,
196 | properties: {
197 | category: "language",
198 | popularity: this.getTechnologyPopularity(
199 | memory.data.language.primary,
200 | ),
201 | },
202 | weight: 1.0,
203 | });
204 |
205 | this.addEdge({
206 | source: projectNode.id,
207 | target: langNode.id,
208 | type: "uses",
209 | weight: 1.0,
210 | confidence: 0.9,
211 | properties: { source: "analysis" },
212 | });
213 | }
214 |
215 | // Create framework nodes
216 | if (memory.data.framework?.name) {
217 | const frameworkNode = this.addNode({
218 | id: `tech:${memory.data.framework.name}`,
219 | type: "technology",
220 | label: memory.data.framework.name,
221 | properties: {
222 | category: "framework",
223 | version: memory.data.framework.version,
224 | },
225 | weight: 1.0,
226 | });
227 |
228 | this.addEdge({
229 | source: projectNode.id,
230 | target: frameworkNode.id,
231 | type: "uses",
232 | weight: 1.0,
233 | confidence: 0.8,
234 | properties: { source: "analysis" },
235 | });
236 | }
237 |
238 | // Create SSG recommendation nodes
239 | if (memory.type === "recommendation" && memory.data.recommended) {
240 | const ssgNode = this.addNode({
241 | id: `tech:${memory.data.recommended}`,
242 | type: "technology",
243 | label: memory.data.recommended,
244 | properties: {
245 | category: "ssg",
246 | score: memory.data.score,
247 | },
248 | weight: 1.0,
249 | });
250 |
251 | this.addEdge({
252 | source: projectNode.id,
253 | target: ssgNode.id,
254 | type: "recommends",
255 | weight: memory.data.score || 1.0,
256 | confidence: memory.data.confidence || 0.5,
257 | properties: {
258 | source: "recommendation",
259 | reasoning: memory.data.reasoning,
260 | },
261 | });
262 | }
263 |
264 | // Create outcome nodes
265 | if (memory.type === "deployment") {
266 | const outcomeNode = this.addNode({
267 | id: `outcome:${memory.data.status}:${memory.metadata.ssg}`,
268 | type: "outcome",
269 | label: `${memory.data.status} with ${memory.metadata.ssg}`,
270 | properties: {
271 | status: memory.data.status,
272 | ssg: memory.metadata.ssg,
273 | duration: memory.data.duration,
274 | },
275 | weight: memory.data.status === "success" ? 1.0 : 0.5,
276 | });
277 |
278 | this.addEdge({
279 | source: projectNode.id,
280 | target: outcomeNode.id,
281 | type: "results_in",
282 | weight: 1.0,
283 | confidence: 1.0,
284 | properties: {
285 | timestamp: memory.timestamp,
286 | details: memory.data.details,
287 | },
288 | });
289 | }
290 | }
291 | }
292 |
293 | /**
294 | * Compute additional relationships based on patterns
295 | */
296 | private async computeRelationships(): Promise<void> {
297 | // Find similar projects
298 | await this.computeProjectSimilarity();
299 |
300 | // Find technology dependencies
301 | await this.computeTechnologyDependencies();
302 |
303 | // Find pattern relationships
304 | await this.computePatternRelationships();
305 | }
306 |
307 | /**
308 | * Compute project similarity relationships
309 | */
310 | private async computeProjectSimilarity(): Promise<void> {
311 | const projectNodes = Array.from(this.nodes.values()).filter(
312 | (node) => node.type === "project",
313 | );
314 |
315 | for (let i = 0; i < projectNodes.length; i++) {
316 | for (let j = i + 1; j < projectNodes.length; j++) {
317 | const similarity = this.calculateProjectSimilarity(
318 | projectNodes[i],
319 | projectNodes[j],
320 | );
321 |
322 | if (similarity > 0.7) {
323 | this.addEdge({
324 | source: projectNodes[i].id,
325 | target: projectNodes[j].id,
326 | type: "similar_to",
327 | weight: similarity,
328 | confidence: similarity,
329 | properties: {
330 | computed: true,
331 | similarityScore: similarity,
332 | },
333 | });
334 | }
335 | }
336 | }
337 | }
338 |
339 | /**
340 | * Calculate similarity between two projects
341 | */
342 | private calculateProjectSimilarity(
343 | project1: GraphNode,
344 | project2: GraphNode,
345 | ): number {
346 | const tech1 = this.getConnectedTechnologies(project1.id);
347 | const tech2 = this.getConnectedTechnologies(project2.id);
348 |
349 | if (tech1.size === 0 || tech2.size === 0) return 0;
350 |
351 | const intersection = new Set([...tech1].filter((x) => tech2.has(x)));
352 | const union = new Set([...tech1, ...tech2]);
353 |
354 | return intersection.size / union.size; // Jaccard similarity
355 | }
356 |
357 | /**
358 | * Get technologies connected to a project
359 | */
360 | private getConnectedTechnologies(projectId: string): Set<string> {
361 | const technologies = new Set<string>();
362 | const adjacents = this.adjacencyList.get(projectId) || new Set();
363 |
364 | for (const nodeId of adjacents) {
365 | const node = this.nodes.get(nodeId);
366 | if (node && node.type === "technology") {
367 | technologies.add(nodeId);
368 | }
369 | }
370 |
371 | return technologies;
372 | }
373 |
374 | /**
375 | * Compute technology dependency relationships
376 | */
377 | private async computeTechnologyDependencies(): Promise<void> {
378 | // Define known technology dependencies
379 | const dependencies = new Map([
380 | ["tech:react", ["tech:javascript", "tech:nodejs"]],
381 | ["tech:vue", ["tech:javascript", "tech:nodejs"]],
382 | ["tech:angular", ["tech:typescript", "tech:nodejs"]],
383 | ["tech:gatsby", ["tech:react", "tech:graphql"]],
384 | ["tech:next.js", ["tech:react", "tech:nodejs"]],
385 | ["tech:nuxt.js", ["tech:vue", "tech:nodejs"]],
386 | ["tech:docusaurus", ["tech:react", "tech:markdown"]],
387 | ["tech:jekyll", ["tech:ruby", "tech:markdown"]],
388 | ["tech:hugo", ["tech:go", "tech:markdown"]],
389 | ["tech:mkdocs", ["tech:python", "tech:markdown"]],
390 | ]);
391 |
392 | for (const [tech, deps] of dependencies) {
393 | for (const dep of deps) {
394 | const techNode = this.nodes.get(tech);
395 | const depNode = this.nodes.get(dep);
396 |
397 | if (techNode && depNode) {
398 | this.addEdge({
399 | source: tech,
400 | target: dep,
401 | type: "depends_on",
402 | weight: 0.8,
403 | confidence: 0.9,
404 | properties: {
405 | computed: true,
406 | dependency_type: "runtime",
407 | },
408 | });
409 | }
410 | }
411 | }
412 | }
413 |
414 | /**
415 | * Compute pattern relationships from successful combinations
416 | */
417 | private async computePatternRelationships(): Promise<void> {
418 | const successfulOutcomes = Array.from(this.nodes.values()).filter(
419 | (node) => node.type === "outcome" && node.properties.status === "success",
420 | );
421 |
422 | for (const outcome of successfulOutcomes) {
423 | // Find the path that led to this successful outcome
424 | const incomingEdges = Array.from(this.edges.values()).filter(
425 | (edge) => edge.target === outcome.id,
426 | );
427 |
428 | for (const edge of incomingEdges) {
429 | const sourceNode = this.nodes.get(edge.source);
430 | if (sourceNode && sourceNode.type === "project") {
431 | // Strengthen relationships for successful patterns
432 | this.strengthenSuccessPattern(sourceNode.id, outcome.properties.ssg);
433 | }
434 | }
435 | }
436 | }
437 |
438 | /**
439 | * Strengthen relationships for successful patterns
440 | */
441 | private strengthenSuccessPattern(projectId: string, ssg: string): void {
442 | const ssgNodeId = `tech:${ssg}`;
443 | const edgeId = `${projectId}-recommends-${ssgNodeId}`;
444 | const edge = this.edges.get(edgeId);
445 |
446 | if (edge) {
447 | edge.weight = Math.min(edge.weight * 1.2, 2.0);
448 | edge.confidence = Math.min(edge.confidence * 1.1, 1.0);
449 | }
450 | }
451 |
452 | /**
453 | * Update node and edge weights based on usage patterns
454 | */
455 | private async updateWeights(): Promise<void> {
456 | // Update node weights based on connections
457 | for (const node of this.nodes.values()) {
458 | const connections = this.adjacencyList.get(node.id)?.size || 0;
459 | node.weight = Math.log(connections + 1) / Math.log(10); // Logarithmic scaling
460 | }
461 |
462 | // Update edge weights based on frequency and success
463 | for (const edge of this.edges.values()) {
464 | if (edge.type === "recommends") {
465 | // Find successful outcomes for this recommendation
466 | const targetNode = this.nodes.get(edge.target);
467 | if (targetNode && targetNode.type === "technology") {
468 | const successRate = this.calculateSuccessRate(targetNode.id);
469 | edge.weight *= 1 + successRate;
470 | }
471 | }
472 | }
473 | }
474 |
475 | /**
476 | * Calculate success rate for a technology
477 | */
478 | private calculateSuccessRate(techId: string): number {
479 | const tech = techId.replace("tech:", "");
480 | const outcomes = Array.from(this.nodes.values()).filter(
481 | (node) => node.type === "outcome" && node.properties.ssg === tech,
482 | );
483 |
484 | if (outcomes.length === 0) return 0;
485 |
486 | const successes = outcomes.filter(
487 | (node) => node.properties.status === "success",
488 | ).length;
489 | return successes / outcomes.length;
490 | }
491 |
492 | /**
493 | * Find the shortest path between two nodes
494 | */
495 | findPath(
496 | sourceId: string,
497 | targetId: string,
498 | maxDepth: number = 5,
499 | ): GraphPath | null {
500 | const visited = new Set<string>();
501 | const queue: { nodeId: string; path: GraphPath }[] = [
502 | {
503 | nodeId: sourceId,
504 | path: {
505 | nodes: [this.nodes.get(sourceId)!],
506 | edges: [],
507 | totalWeight: 0,
508 | confidence: 1.0,
509 | },
510 | },
511 | ];
512 |
513 | while (queue.length > 0) {
514 | const current = queue.shift()!;
515 |
516 | if (current.nodeId === targetId) {
517 | return current.path;
518 | }
519 |
520 | if (current.path.nodes.length >= maxDepth) {
521 | continue;
522 | }
523 |
524 | visited.add(current.nodeId);
525 | const neighbors = this.adjacencyList.get(current.nodeId) || new Set();
526 |
527 | for (const neighborId of neighbors) {
528 | if (visited.has(neighborId)) continue;
529 |
530 | const edge = this.findEdge(current.nodeId, neighborId);
531 | const neighborNode = this.nodes.get(neighborId);
532 |
533 | if (edge && neighborNode) {
534 | const newPath: GraphPath = {
535 | nodes: [...current.path.nodes, neighborNode],
536 | edges: [...current.path.edges, edge],
537 | totalWeight: current.path.totalWeight + edge.weight,
538 | confidence: current.path.confidence * edge.confidence,
539 | };
540 |
541 | queue.push({ nodeId: neighborId, path: newPath });
542 | }
543 | }
544 | }
545 |
546 | return null;
547 | }
548 |
549 | /**
550 | * Find edge between two nodes
551 | */
552 | private findEdge(sourceId: string, targetId: string): GraphEdge | null {
553 | for (const edge of this.edges.values()) {
554 | if (edge.source === sourceId && edge.target === targetId) {
555 | return edge;
556 | }
557 | }
558 | return null;
559 | }
560 |
561 | /**
562 | * Query the knowledge graph
563 | */
564 | query(query: GraphQuery): {
565 | nodes: GraphNode[];
566 | edges: GraphEdge[];
567 | paths?: GraphPath[];
568 | } {
569 | let nodes = Array.from(this.nodes.values());
570 | let edges = Array.from(this.edges.values());
571 |
572 | // Filter by node types
573 | if (query.nodeTypes) {
574 | nodes = nodes.filter((node) => query.nodeTypes!.includes(node.type));
575 | }
576 |
577 | // Filter by edge types
578 | if (query.edgeTypes) {
579 | edges = edges.filter((edge) => query.edgeTypes!.includes(edge.type));
580 | }
581 |
582 | // Filter by properties
583 | if (query.properties) {
584 | nodes = nodes.filter((node) =>
585 | Object.entries(query.properties!).every(
586 | ([key, value]) => node.properties[key] === value,
587 | ),
588 | );
589 | }
590 |
591 | // Filter by minimum weight
592 | if (query.minWeight) {
593 | nodes = nodes.filter((node) => node.weight >= query.minWeight!);
594 | edges = edges.filter((edge) => edge.weight >= query.minWeight!);
595 | }
596 |
597 | const result = { nodes, edges };
598 |
599 | // Find paths from start node if specified
600 | if (query.startNode && query.maxDepth) {
601 | const paths: GraphPath[] = [];
602 | const visited = new Set<string>();
603 |
604 | const emptyPath: GraphPath = {
605 | nodes: [],
606 | edges: [],
607 | totalWeight: 0,
608 | confidence: 1.0,
609 | };
610 | this.explorePaths(
611 | query.startNode,
612 | emptyPath,
613 | paths,
614 | visited,
615 | query.maxDepth,
616 | );
617 | (result as any).paths = paths;
618 | }
619 |
620 | return result;
621 | }
622 |
623 | /**
624 | * Explore paths from a starting node
625 | */
626 | private explorePaths(
627 | nodeId: string,
628 | currentPath: GraphPath,
629 | allPaths: GraphPath[],
630 | visited: Set<string>,
631 | maxDepth: number,
632 | ): void {
633 | if (currentPath.nodes.length >= maxDepth) return;
634 |
635 | visited.add(nodeId);
636 | const neighbors = this.adjacencyList.get(nodeId) || new Set();
637 |
638 | for (const neighborId of neighbors) {
639 | if (visited.has(neighborId)) continue;
640 |
641 | const edge = this.findEdge(nodeId, neighborId);
642 | const neighborNode = this.nodes.get(neighborId);
643 |
644 | if (edge && neighborNode) {
645 | const newPath: GraphPath = {
646 | nodes: [...(currentPath.nodes || []), neighborNode],
647 | edges: [...(currentPath.edges || []), edge],
648 | totalWeight: (currentPath.totalWeight || 0) + edge.weight,
649 | confidence: (currentPath.confidence || 1.0) * edge.confidence,
650 | };
651 |
652 | allPaths.push(newPath);
653 | this.explorePaths(
654 | neighborId,
655 | newPath,
656 | allPaths,
657 | new Set(visited),
658 | maxDepth,
659 | );
660 | }
661 | }
662 | }
663 |
664 | /**
665 | * Get enhanced recommendations using knowledge graph
666 | */
667 | async getGraphBasedRecommendation(
668 | projectFeatures: any,
669 | candidateSSGs: string[],
670 | ): Promise<RecommendationPath[]> {
671 | const recommendations: RecommendationPath[] = [];
672 |
673 | // Create a temporary project node
674 | const tempProjectId = `temp:${Date.now()}`;
675 | const projectNode = this.addNode({
676 | id: tempProjectId,
677 | type: "project",
678 | label: "Query Project",
679 | properties: projectFeatures,
680 | weight: 1.0,
681 | });
682 |
683 | for (const ssg of candidateSSGs) {
684 | const ssgNodeId = `tech:${ssg}`;
685 | const ssgNode = this.nodes.get(ssgNodeId);
686 |
687 | if (ssgNode) {
688 | // Find paths from similar projects to this SSG
689 | const similarProjects = this.findSimilarProjects(projectFeatures);
690 |
691 | for (const similarProject of similarProjects) {
692 | const path = this.findPath(similarProject.id, ssgNodeId);
693 |
694 | if (path) {
695 | const reasoning = this.generateReasoning(path);
696 | const confidence = this.calculatePathConfidence(
697 | path,
698 | projectFeatures,
699 | );
700 |
701 | recommendations.push({
702 | from: projectNode,
703 | to: ssgNode,
704 | path,
705 | reasoning,
706 | confidence,
707 | });
708 | }
709 | }
710 | }
711 | }
712 |
713 | // Clean up temporary node
714 | this.nodes.delete(tempProjectId);
715 |
716 | return recommendations.sort((a, b) => b.confidence - a.confidence);
717 | }
718 |
719 | /**
720 | * Find projects similar to given features
721 | */
722 | private findSimilarProjects(features: any): GraphNode[] {
723 | const projectNodes = Array.from(this.nodes.values()).filter(
724 | (node) => node.type === "project",
725 | );
726 |
727 | return projectNodes
728 | .map((project) => ({
729 | project,
730 | similarity: this.calculateFeatureSimilarity(
731 | features,
732 | project.properties,
733 | ),
734 | }))
735 | .filter(({ similarity }) => similarity > 0.6)
736 | .sort((a, b) => b.similarity - a.similarity)
737 | .slice(0, 5)
738 | .map(({ project }) => project);
739 | }
740 |
741 | /**
742 | * Calculate similarity between features and project properties
743 | */
744 | private calculateFeatureSimilarity(features: any, properties: any): number {
745 | let score = 0;
746 | let factors = 0;
747 |
748 | if (features.language === properties.language) {
749 | score += 0.4;
750 | }
751 | factors++;
752 |
753 | if (features.framework === properties.framework) {
754 | score += 0.3;
755 | }
756 | factors++;
757 |
758 | if (features.size === properties.size) {
759 | score += 0.2;
760 | }
761 | factors++;
762 |
763 | if (features.complexity === properties.complexity) {
764 | score += 0.1;
765 | }
766 | factors++;
767 |
768 | return factors > 0 ? score / factors : 0;
769 | }
770 |
771 | /**
772 | * Generate human-readable reasoning for a recommendation path
773 | */
774 | private generateReasoning(path: GraphPath): string[] {
775 | const reasoning: string[] = [];
776 |
777 | for (let i = 0; i < path.edges.length; i++) {
778 | const edge = path.edges[i];
779 | const sourceNode = path.nodes[i];
780 | const targetNode = path.nodes[i + 1];
781 |
782 | switch (edge.type) {
783 | case "similar_to":
784 | reasoning.push(
785 | `Similar to ${sourceNode.label} (${(edge.confidence * 100).toFixed(
786 | 0,
787 | )}% similarity)`,
788 | );
789 | break;
790 | case "recommends":
791 | reasoning.push(
792 | `Successfully used ${
793 | targetNode.label
794 | } (score: ${edge.weight.toFixed(1)})`,
795 | );
796 | break;
797 | case "results_in":
798 | reasoning.push(
799 | `Resulted in ${targetNode.properties.status} deployment`,
800 | );
801 | break;
802 | case "uses":
803 | reasoning.push(`Uses ${targetNode.label}`);
804 | break;
805 | }
806 | }
807 |
808 | return reasoning;
809 | }
810 |
811 | /**
812 | * Calculate confidence for a recommendation path
813 | */
814 | private calculatePathConfidence(path: GraphPath, _features: any): number {
815 | let confidence = path.confidence;
816 |
817 | // Boost confidence for shorter paths
818 | confidence *= 1 / Math.max(path.edges.length, 1);
819 |
820 | // Boost confidence for recent data
821 | const avgAge =
822 | path.nodes.reduce((sum, node) => {
823 | const age = Date.now() - new Date(node.lastUpdated).getTime();
824 | return sum + age;
825 | }, 0) / path.nodes.length;
826 |
827 | const daysSinceUpdate = avgAge / (1000 * 60 * 60 * 24);
828 | confidence *= Math.exp(-daysSinceUpdate / 30); // Exponential decay over 30 days
829 |
830 | return Math.min(confidence, 1.0);
831 | }
832 |
833 | /**
834 | * Get technology popularity score
835 | */
836 | private getTechnologyPopularity(tech: string): number {
837 | // Simple popularity scoring - could be enhanced with real data
838 | const popularityMap = new Map([
839 | ["javascript", 0.9],
840 | ["typescript", 0.8],
841 | ["python", 0.8],
842 | ["react", 0.9],
843 | ["vue", 0.7],
844 | ["angular", 0.6],
845 | ["go", 0.7],
846 | ["ruby", 0.5],
847 | ["rust", 0.6],
848 | ]);
849 |
850 | return popularityMap.get(tech.toLowerCase()) || 0.3;
851 | }
852 |
853 | /**
854 | * Save knowledge graph to persistent memory
855 | */
856 | async saveToMemory(): Promise<void> {
857 | const graphData = {
858 | nodes: Array.from(this.nodes.entries()),
859 | edges: Array.from(this.edges.entries()),
860 | lastUpdate: this.lastUpdate,
861 | statistics: this.getStatistics(),
862 | };
863 |
864 | await this.memoryManager.remember(
865 | "interaction",
866 | {
867 | graph: graphData,
868 | type: "knowledge_graph",
869 | },
870 | {
871 | tags: ["knowledge_graph", "structure"],
872 | },
873 | );
874 | }
875 |
876 | /**
877 | * Load knowledge graph from persistent memory
878 | */
879 | async loadFromMemory(): Promise<void> {
880 | try {
881 | const graphMemories = await this.memoryManager.search("knowledge_graph");
882 |
883 | if (graphMemories.length > 0) {
884 | const latestGraph = graphMemories.sort(
885 | (a, b) =>
886 | new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
887 | )[0];
888 |
889 | if (latestGraph.data.graph) {
890 | const { nodes, edges } = latestGraph.data.graph;
891 |
892 | // Restore nodes
893 | for (const [id, node] of nodes) {
894 | this.nodes.set(id, node);
895 | }
896 |
897 | // Restore edges and adjacency list
898 | for (const [id, edge] of edges) {
899 | this.edges.set(id, edge);
900 |
901 | if (!this.adjacencyList.has(edge.source)) {
902 | this.adjacencyList.set(edge.source, new Set());
903 | }
904 | this.adjacencyList.get(edge.source)!.add(edge.target);
905 | }
906 |
907 | this.lastUpdate = latestGraph.data.graph.lastUpdate;
908 | }
909 | }
910 | } catch (error) {
911 | console.error("Failed to load knowledge graph from memory:", error);
912 | }
913 | }
914 |
915 | /**
916 | * Get all nodes in the knowledge graph
917 | */
918 | async getAllNodes(): Promise<GraphNode[]> {
919 | return Array.from(this.nodes.values());
920 | }
921 |
922 | /**
923 | * Get all edges in the knowledge graph
924 | */
925 | async getAllEdges(): Promise<GraphEdge[]> {
926 | return Array.from(this.edges.values());
927 | }
928 |
929 | /**
930 | * Get a node by its ID
931 | */
932 | async getNodeById(nodeId: string): Promise<GraphNode | null> {
933 | return this.nodes.get(nodeId) || null;
934 | }
935 |
936 | /**
937 | * Remove a node from the knowledge graph
938 | */
939 | async removeNode(nodeId: string): Promise<boolean> {
940 | const node = this.nodes.get(nodeId);
941 | if (!node) {
942 | return false;
943 | }
944 |
945 | // Remove the node
946 | this.nodes.delete(nodeId);
947 |
948 | // Remove all edges connected to this node
949 | const edgesToRemove: string[] = [];
950 | for (const [edgeId, edge] of this.edges) {
951 | if (edge.source === nodeId || edge.target === nodeId) {
952 | edgesToRemove.push(edgeId);
953 | }
954 | }
955 |
956 | for (const edgeId of edgesToRemove) {
957 | this.edges.delete(edgeId);
958 | }
959 |
960 | // Update adjacency list
961 | this.adjacencyList.delete(nodeId);
962 | for (const [, targets] of this.adjacencyList) {
963 | targets.delete(nodeId);
964 | }
965 |
966 | return true;
967 | }
968 |
969 | /**
970 | * Get connections for a specific node
971 | */
972 | async getConnections(nodeId: string): Promise<string[]> {
973 | const connections = this.adjacencyList.get(nodeId);
974 | return connections ? Array.from(connections) : [];
975 | }
976 |
977 | /**
978 | * Get knowledge graph statistics
979 | */
980 | async getStatistics(): Promise<{
981 | nodeCount: number;
982 | edgeCount: number;
983 | nodesByType: Record<string, number>;
984 | edgesByType: Record<string, number>;
985 | averageConnectivity: number;
986 | mostConnectedNodes: Array<{ id: string; connections: number }>;
987 | }> {
988 | const nodesByType: Record<string, number> = {};
989 | const edgesByType: Record<string, number> = {};
990 |
991 | for (const node of this.nodes.values()) {
992 | nodesByType[node.type] = (nodesByType[node.type] || 0) + 1;
993 | }
994 |
995 | for (const edge of this.edges.values()) {
996 | edgesByType[edge.type] = (edgesByType[edge.type] || 0) + 1;
997 | }
998 |
999 | const connectivityCounts = Array.from(this.adjacencyList.entries())
1000 | .map(([id, connections]) => ({ id, connections: connections.size }))
1001 | .sort((a, b) => b.connections - a.connections);
1002 |
1003 | const averageConnectivity =
1004 | connectivityCounts.length > 0
1005 | ? connectivityCounts.reduce(
1006 | (sum, { connections }) => sum + connections,
1007 | 0,
1008 | ) / connectivityCounts.length
1009 | : 0;
1010 |
1011 | return {
1012 | nodeCount: this.nodes.size,
1013 | edgeCount: this.edges.size,
1014 | nodesByType,
1015 | edgesByType,
1016 | averageConnectivity,
1017 | mostConnectedNodes: connectivityCounts.slice(0, 10),
1018 | };
1019 | }
1020 |
1021 | // ============================================================================
1022 | // Phase 1.1: Enhanced Node Query Methods
1023 | // ============================================================================
1024 |
1025 | /**
1026 | * Find a single node matching criteria
1027 | */
1028 | async findNode(criteria: {
1029 | type?: string;
1030 | properties?: Record<string, any>;
1031 | }): Promise<GraphNode | null> {
1032 | for (const node of this.nodes.values()) {
1033 | if (criteria.type && node.type !== criteria.type) continue;
1034 |
1035 | if (criteria.properties) {
1036 | let matches = true;
1037 | for (const [key, value] of Object.entries(criteria.properties)) {
1038 | if (node.properties[key] !== value) {
1039 | matches = false;
1040 | break;
1041 | }
1042 | }
1043 | if (!matches) continue;
1044 | }
1045 |
1046 | return node;
1047 | }
1048 |
1049 | return null;
1050 | }
1051 |
1052 | /**
1053 | * Find all nodes matching criteria
1054 | */
1055 | async findNodes(criteria: {
1056 | type?: string;
1057 | properties?: Record<string, any>;
1058 | }): Promise<GraphNode[]> {
1059 | const results: GraphNode[] = [];
1060 |
1061 | for (const node of this.nodes.values()) {
1062 | if (criteria.type && node.type !== criteria.type) continue;
1063 |
1064 | if (criteria.properties) {
1065 | let matches = true;
1066 | for (const [key, value] of Object.entries(criteria.properties)) {
1067 | if (node.properties[key] !== value) {
1068 | matches = false;
1069 | break;
1070 | }
1071 | }
1072 | if (!matches) continue;
1073 | }
1074 |
1075 | results.push(node);
1076 | }
1077 |
1078 | return results;
1079 | }
1080 |
1081 | /**
1082 | * Find edges matching criteria
1083 | */
1084 | async findEdges(criteria: {
1085 | source?: string;
1086 | target?: string;
1087 | type?: string;
1088 | properties?: Record<string, any>;
1089 | }): Promise<GraphEdge[]> {
1090 | const results: GraphEdge[] = [];
1091 |
1092 | for (const edge of this.edges.values()) {
1093 | if (criteria.source && edge.source !== criteria.source) continue;
1094 | if (criteria.target && edge.target !== criteria.target) continue;
1095 | if (criteria.type && edge.type !== criteria.type) continue;
1096 |
1097 | // Match properties if provided
1098 | if (criteria.properties) {
1099 | let propertiesMatch = true;
1100 | for (const [key, value] of Object.entries(criteria.properties)) {
1101 | if (edge.properties[key] !== value) {
1102 | propertiesMatch = false;
1103 | break;
1104 | }
1105 | }
1106 | if (!propertiesMatch) continue;
1107 | }
1108 |
1109 | results.push(edge);
1110 | }
1111 |
1112 | return results;
1113 | }
1114 |
1115 | /**
1116 | * Find all paths between two nodes up to a maximum depth
1117 | */
1118 | async findPaths(criteria: {
1119 | startNode: string;
1120 | endNode?: string;
1121 | edgeTypes?: string[];
1122 | maxDepth: number;
1123 | }): Promise<GraphPath[]> {
1124 | const paths: GraphPath[] = [];
1125 | const visited = new Set<string>();
1126 |
1127 | const emptyPath: GraphPath = {
1128 | nodes: [this.nodes.get(criteria.startNode)!],
1129 | edges: [],
1130 | totalWeight: 0,
1131 | confidence: 1.0,
1132 | };
1133 |
1134 | this.findPathsRecursive(
1135 | criteria.startNode,
1136 | emptyPath,
1137 | paths,
1138 | visited,
1139 | criteria.maxDepth,
1140 | criteria.endNode,
1141 | criteria.edgeTypes,
1142 | );
1143 |
1144 | return paths;
1145 | }
1146 |
1147 | /**
1148 | * Recursive helper for finding paths
1149 | */
1150 | private findPathsRecursive(
1151 | currentNodeId: string,
1152 | currentPath: GraphPath,
1153 | allPaths: GraphPath[],
1154 | visited: Set<string>,
1155 | maxDepth: number,
1156 | endNode?: string,
1157 | edgeTypes?: string[],
1158 | ): void {
1159 | if (currentPath.nodes.length >= maxDepth) return;
1160 |
1161 | visited.add(currentNodeId);
1162 | const neighbors = this.adjacencyList.get(currentNodeId) || new Set();
1163 |
1164 | for (const neighborId of neighbors) {
1165 | if (visited.has(neighborId)) continue;
1166 |
1167 | const edge = this.findEdge(currentNodeId, neighborId);
1168 | if (!edge) continue;
1169 |
1170 | // Filter by edge type if specified
1171 | if (edgeTypes && !edgeTypes.includes(edge.type)) continue;
1172 |
1173 | const neighborNode = this.nodes.get(neighborId);
1174 | if (!neighborNode) continue;
1175 |
1176 | const newPath: GraphPath = {
1177 | nodes: [...currentPath.nodes, neighborNode],
1178 | edges: [...currentPath.edges, edge],
1179 | totalWeight: currentPath.totalWeight + edge.weight,
1180 | confidence: currentPath.confidence * edge.confidence,
1181 | };
1182 |
1183 | // If we've reached the end node, add this path
1184 | if (endNode && neighborId === endNode) {
1185 | allPaths.push(newPath);
1186 | continue;
1187 | }
1188 |
1189 | // If no end node specified, add all paths
1190 | if (!endNode) {
1191 | allPaths.push(newPath);
1192 | }
1193 |
1194 | // Continue exploring
1195 | this.findPathsRecursive(
1196 | neighborId,
1197 | newPath,
1198 | allPaths,
1199 | new Set(visited),
1200 | maxDepth,
1201 | endNode,
1202 | edgeTypes,
1203 | );
1204 | }
1205 | }
1206 |
1207 | /**
1208 | * Get node history (all changes to a node over time)
1209 | */
1210 | async getNodeHistory(nodeId: string): Promise<MemoryEntry[]> {
1211 | const node = this.nodes.get(nodeId);
1212 | if (!node) return [];
1213 |
1214 | // Search memory for all entries related to this node
1215 | const projectId = node.properties.projectId || node.properties.path;
1216 | if (!projectId) return [];
1217 |
1218 | return await this.memoryManager.search(projectId);
1219 | }
1220 |
1221 | /**
1222 | * Get schema version
1223 | */
1224 | getSchemaVersion(): string {
1225 | return SCHEMA_METADATA.version;
1226 | }
1227 |
1228 | /**
1229 | * Validate node against schema
1230 | */
1231 | validateNode(node: GraphNode): boolean {
1232 | try {
1233 | const entityData = {
1234 | ...node.properties,
1235 | type: node.type,
1236 | };
1237 | validateEntity(entityData);
1238 | return true;
1239 | } catch (error) {
1240 | console.error(`Node validation failed for ${node.id}:`, error);
1241 | return false;
1242 | }
1243 | }
1244 |
1245 | /**
1246 | * Validate edge against schema
1247 | */
1248 | validateEdge(edge: GraphEdge): boolean {
1249 | try {
1250 | const relationshipData = {
1251 | type: edge.type,
1252 | weight: edge.weight,
1253 | confidence: edge.confidence,
1254 | createdAt: edge.lastUpdated,
1255 | lastUpdated: edge.lastUpdated,
1256 | metadata: edge.properties,
1257 | ...edge.properties,
1258 | };
1259 | validateRelationship(relationshipData);
1260 | return true;
1261 | } catch (error) {
1262 | console.error(`Edge validation failed for ${edge.id}:`, error);
1263 | return false;
1264 | }
1265 | }
1266 | }
1267 |
1268 | export default KnowledgeGraph;
1269 |
```