This is page 19 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
--------------------------------------------------------------------------------
/tests/utils/sitemap-generator.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for sitemap-generator utility
3 | */
4 |
5 | import { promises as fs } from "fs";
6 | import path from "path";
7 | import { tmpdir } from "os";
8 | import {
9 | generateSitemap,
10 | parseSitemap,
11 | validateSitemap,
12 | updateSitemap,
13 | listSitemapUrls,
14 | type SitemapUrl,
15 | type SitemapOptions,
16 | } from "../../src/utils/sitemap-generator.js";
17 |
18 | describe("sitemap-generator", () => {
19 | let testDir: string;
20 | let docsDir: string;
21 |
22 | beforeEach(async () => {
23 | // Create temporary test directory
24 | testDir = path.join(tmpdir(), `sitemap-test-${Date.now()}`);
25 | docsDir = path.join(testDir, "docs");
26 | await fs.mkdir(docsDir, { recursive: true });
27 | });
28 |
29 | afterEach(async () => {
30 | // Clean up test directory
31 | try {
32 | await fs.rm(testDir, { recursive: true, force: true });
33 | } catch (error) {
34 | // Ignore cleanup errors
35 | }
36 | });
37 |
38 | describe("generateSitemap", () => {
39 | it("should generate sitemap.xml from documentation files", async () => {
40 | // Create test documentation structure
41 | await fs.mkdir(path.join(docsDir, "tutorials"), { recursive: true });
42 | await fs.mkdir(path.join(docsDir, "reference"), { recursive: true });
43 |
44 | await fs.writeFile(
45 | path.join(docsDir, "index.md"),
46 | "# Home\n\nWelcome to the docs!",
47 | );
48 | await fs.writeFile(
49 | path.join(docsDir, "tutorials", "getting-started.md"),
50 | "# Getting Started\n\nStart here.",
51 | );
52 | await fs.writeFile(
53 | path.join(docsDir, "reference", "api.md"),
54 | "# API Reference\n\nAPI documentation.",
55 | );
56 |
57 | const options: SitemapOptions = {
58 | baseUrl: "https://example.com",
59 | docsPath: docsDir,
60 | useGitHistory: false,
61 | };
62 |
63 | const result = await generateSitemap(options);
64 |
65 | expect(result.xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
66 | expect(result.xml).toContain(
67 | '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
68 | );
69 | expect(result.urls).toHaveLength(3);
70 | expect(result.stats.totalUrls).toBe(3);
71 | expect(result.stats.byCategory).toHaveProperty("home");
72 | expect(result.stats.byCategory).toHaveProperty("tutorial");
73 | expect(result.stats.byCategory).toHaveProperty("reference");
74 | });
75 |
76 | it("should generate URLs with correct priorities based on categories", async () => {
77 | await fs.mkdir(path.join(docsDir, "tutorials"), { recursive: true });
78 | await fs.mkdir(path.join(docsDir, "reference"), { recursive: true });
79 |
80 | await fs.writeFile(
81 | path.join(docsDir, "tutorials", "guide.md"),
82 | "# Tutorial",
83 | );
84 | await fs.writeFile(
85 | path.join(docsDir, "reference", "api.md"),
86 | "# Reference",
87 | );
88 |
89 | const result = await generateSitemap({
90 | baseUrl: "https://example.com",
91 | docsPath: docsDir,
92 | useGitHistory: false,
93 | });
94 |
95 | const tutorialUrl = result.urls.find((u) => u.category === "tutorial");
96 | const referenceUrl = result.urls.find((u) => u.category === "reference");
97 |
98 | expect(tutorialUrl?.priority).toBe(1.0); // Highest priority
99 | expect(referenceUrl?.priority).toBe(0.8);
100 | });
101 |
102 | it("should handle empty documentation directory", async () => {
103 | const result = await generateSitemap({
104 | baseUrl: "https://example.com",
105 | docsPath: docsDir,
106 | useGitHistory: false,
107 | });
108 |
109 | expect(result.urls).toHaveLength(0);
110 | expect(result.stats.totalUrls).toBe(0);
111 | });
112 |
113 | it("should exclude node_modules and other excluded patterns", async () => {
114 | await fs.mkdir(path.join(docsDir, "node_modules"), { recursive: true });
115 | await fs.writeFile(
116 | path.join(docsDir, "node_modules", "package.md"),
117 | "# Package",
118 | );
119 | await fs.writeFile(path.join(docsDir, "guide.md"), "# Guide");
120 |
121 | const result = await generateSitemap({
122 | baseUrl: "https://example.com",
123 | docsPath: docsDir,
124 | useGitHistory: false,
125 | });
126 |
127 | expect(result.urls).toHaveLength(1);
128 | expect(result.urls[0].loc).toContain("guide.html");
129 | });
130 |
131 | it("should convert markdown extensions to html", async () => {
132 | await fs.writeFile(path.join(docsDir, "page.md"), "# Page");
133 | await fs.writeFile(path.join(docsDir, "component.mdx"), "# Component");
134 |
135 | const result = await generateSitemap({
136 | baseUrl: "https://example.com",
137 | docsPath: docsDir,
138 | useGitHistory: false,
139 | });
140 |
141 | expect(result.urls[0].loc).toContain(".html");
142 | expect(result.urls[1].loc).toContain(".html");
143 | expect(result.urls.some((u) => u.loc.endsWith(".md"))).toBe(false);
144 | expect(result.urls.some((u) => u.loc.endsWith(".mdx"))).toBe(false);
145 | });
146 |
147 | it("should extract title from markdown frontmatter", async () => {
148 | const content = `---
149 | title: My Custom Title
150 | ---
151 |
152 | # Main Heading
153 |
154 | Content here.`;
155 |
156 | await fs.writeFile(path.join(docsDir, "page.md"), content);
157 |
158 | const result = await generateSitemap({
159 | baseUrl: "https://example.com",
160 | docsPath: docsDir,
161 | useGitHistory: false,
162 | });
163 |
164 | expect(result.urls[0].title).toBe("My Custom Title");
165 | });
166 |
167 | it("should extract title from markdown heading", async () => {
168 | await fs.writeFile(
169 | path.join(docsDir, "page.md"),
170 | "# Page Title\n\nContent",
171 | );
172 |
173 | const result = await generateSitemap({
174 | baseUrl: "https://example.com",
175 | docsPath: docsDir,
176 | useGitHistory: false,
177 | });
178 |
179 | expect(result.urls[0].title).toBe("Page Title");
180 | });
181 |
182 | it("should handle custom include and exclude patterns", async () => {
183 | await fs.writeFile(path.join(docsDir, "page.md"), "# Markdown");
184 | await fs.writeFile(path.join(docsDir, "page.html"), "<h1>HTML</h1>");
185 | await fs.writeFile(path.join(docsDir, "page.txt"), "Text");
186 |
187 | const result = await generateSitemap({
188 | baseUrl: "https://example.com",
189 | docsPath: docsDir,
190 | includePatterns: ["**/*.md"],
191 | excludePatterns: [],
192 | useGitHistory: false,
193 | });
194 |
195 | expect(result.urls).toHaveLength(1);
196 | expect(result.urls[0].loc).toContain("page.html");
197 | });
198 | });
199 |
200 | describe("parseSitemap", () => {
201 | it("should parse existing sitemap.xml", async () => {
202 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
203 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
204 | <url>
205 | <loc>https://example.com/page1.html</loc>
206 | <lastmod>2025-01-01</lastmod>
207 | <changefreq>monthly</changefreq>
208 | <priority>0.8</priority>
209 | </url>
210 | <url>
211 | <loc>https://example.com/page2.html</loc>
212 | <lastmod>2025-01-02</lastmod>
213 | <changefreq>weekly</changefreq>
214 | <priority>1.0</priority>
215 | </url>
216 | </urlset>`;
217 |
218 | const sitemapPath = path.join(testDir, "sitemap.xml");
219 | await fs.writeFile(sitemapPath, xml);
220 |
221 | const urls = await parseSitemap(sitemapPath);
222 |
223 | expect(urls).toHaveLength(2);
224 | expect(urls[0].loc).toBe("https://example.com/page1.html");
225 | expect(urls[0].lastmod).toBe("2025-01-01");
226 | expect(urls[0].changefreq).toBe("monthly");
227 | expect(urls[0].priority).toBe(0.8);
228 | expect(urls[1].loc).toBe("https://example.com/page2.html");
229 | });
230 |
231 | it("should handle XML special characters", async () => {
232 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
233 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
234 | <url>
235 | <loc>https://example.com/page?id=1&type=test</loc>
236 | </url>
237 | </urlset>`;
238 |
239 | const sitemapPath = path.join(testDir, "sitemap.xml");
240 | await fs.writeFile(sitemapPath, xml);
241 |
242 | const urls = await parseSitemap(sitemapPath);
243 |
244 | expect(urls[0].loc).toBe("https://example.com/page?id=1&type=test");
245 | });
246 |
247 | it("should throw error for invalid sitemap", async () => {
248 | const sitemapPath = path.join(testDir, "invalid.xml");
249 | await fs.writeFile(sitemapPath, "not xml");
250 |
251 | const urls = await parseSitemap(sitemapPath);
252 | expect(urls).toHaveLength(0); // Graceful handling of invalid XML
253 | });
254 | });
255 |
256 | describe("validateSitemap", () => {
257 | it("should validate correct sitemap", async () => {
258 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
259 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
260 | <url>
261 | <loc>https://example.com/page.html</loc>
262 | <lastmod>2025-01-01</lastmod>
263 | <changefreq>monthly</changefreq>
264 | <priority>0.8</priority>
265 | </url>
266 | </urlset>`;
267 |
268 | const sitemapPath = path.join(testDir, "sitemap.xml");
269 | await fs.writeFile(sitemapPath, xml);
270 |
271 | const result = await validateSitemap(sitemapPath);
272 |
273 | expect(result.valid).toBe(true);
274 | expect(result.errors).toHaveLength(0);
275 | expect(result.urlCount).toBe(1);
276 | });
277 |
278 | it("should detect missing loc element", async () => {
279 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
280 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
281 | <url>
282 | <lastmod>2025-01-01</lastmod>
283 | </url>
284 | </urlset>`;
285 |
286 | const sitemapPath = path.join(testDir, "sitemap.xml");
287 | await fs.writeFile(sitemapPath, xml);
288 |
289 | const result = await validateSitemap(sitemapPath);
290 |
291 | expect(result.valid).toBe(false);
292 | expect(result.errors.some((e) => e.includes("Missing <loc>"))).toBe(true);
293 | });
294 |
295 | it("should detect invalid priority", async () => {
296 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
297 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
298 | <url>
299 | <loc>https://example.com/page.html</loc>
300 | <priority>1.5</priority>
301 | </url>
302 | </urlset>`;
303 |
304 | const sitemapPath = path.join(testDir, "sitemap.xml");
305 | await fs.writeFile(sitemapPath, xml);
306 |
307 | const result = await validateSitemap(sitemapPath);
308 |
309 | expect(result.valid).toBe(false);
310 | expect(
311 | result.errors.some((e) =>
312 | e.includes("Priority must be between 0.0 and 1.0"),
313 | ),
314 | ).toBe(true);
315 | });
316 |
317 | it("should detect invalid protocol", async () => {
318 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
319 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
320 | <url>
321 | <loc>ftp://example.com/page.html</loc>
322 | </url>
323 | </urlset>`;
324 |
325 | const sitemapPath = path.join(testDir, "sitemap.xml");
326 | await fs.writeFile(sitemapPath, xml);
327 |
328 | const result = await validateSitemap(sitemapPath);
329 |
330 | expect(result.valid).toBe(false);
331 | expect(result.errors.some((e) => e.includes("Invalid protocol"))).toBe(
332 | true,
333 | );
334 | });
335 |
336 | it("should return error if sitemap does not exist", async () => {
337 | const sitemapPath = path.join(testDir, "nonexistent.xml");
338 |
339 | const result = await validateSitemap(sitemapPath);
340 |
341 | expect(result.valid).toBe(false);
342 | expect(result.errors.some((e) => e.includes("does not exist"))).toBe(
343 | true,
344 | );
345 | });
346 | });
347 |
348 | describe("updateSitemap", () => {
349 | it("should update existing sitemap with new pages", async () => {
350 | // Create initial sitemap
351 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
352 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
353 | <url>
354 | <loc>https://example.com/page1.html</loc>
355 | <lastmod>2025-01-01</lastmod>
356 | <priority>0.8</priority>
357 | </url>
358 | </urlset>`;
359 |
360 | const sitemapPath = path.join(testDir, "sitemap.xml");
361 | await fs.writeFile(sitemapPath, xml);
362 |
363 | // Add new documentation file
364 | await fs.writeFile(path.join(docsDir, "page1.md"), "# Page 1");
365 | await fs.writeFile(path.join(docsDir, "page2.md"), "# Page 2");
366 |
367 | const changes = await updateSitemap(sitemapPath, {
368 | baseUrl: "https://example.com",
369 | docsPath: docsDir,
370 | useGitHistory: false,
371 | });
372 |
373 | expect(changes.added).toBe(1); // page2.md is new
374 | expect(changes.total).toBe(2);
375 | });
376 |
377 | it("should detect removed pages", async () => {
378 | // Create initial sitemap with 2 URLs
379 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
380 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
381 | <url>
382 | <loc>https://example.com/page1.html</loc>
383 | </url>
384 | <url>
385 | <loc>https://example.com/page2.html</loc>
386 | </url>
387 | </urlset>`;
388 |
389 | const sitemapPath = path.join(testDir, "sitemap.xml");
390 | await fs.writeFile(sitemapPath, xml);
391 |
392 | // Only create page1.md
393 | await fs.writeFile(path.join(docsDir, "page1.md"), "# Page 1");
394 |
395 | const changes = await updateSitemap(sitemapPath, {
396 | baseUrl: "https://example.com",
397 | docsPath: docsDir,
398 | useGitHistory: false,
399 | });
400 |
401 | expect(changes.removed).toBe(1); // page2.html was removed
402 | expect(changes.total).toBe(1);
403 | });
404 |
405 | it("should create new sitemap if none exists", async () => {
406 | const sitemapPath = path.join(testDir, "sitemap.xml");
407 | await fs.writeFile(path.join(docsDir, "page.md"), "# Page");
408 |
409 | const changes = await updateSitemap(sitemapPath, {
410 | baseUrl: "https://example.com",
411 | docsPath: docsDir,
412 | useGitHistory: false,
413 | });
414 |
415 | expect(changes.added).toBe(1);
416 | expect(changes.total).toBe(1);
417 |
418 | // Verify sitemap was created
419 | const exists = await fs
420 | .access(sitemapPath)
421 | .then(() => true)
422 | .catch(() => false);
423 | expect(exists).toBe(true);
424 | });
425 | });
426 |
427 | describe("listSitemapUrls", () => {
428 | it("should list all URLs from sitemap", async () => {
429 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
430 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
431 | <url>
432 | <loc>https://example.com/page1.html</loc>
433 | <priority>0.9</priority>
434 | </url>
435 | <url>
436 | <loc>https://example.com/page2.html</loc>
437 | <priority>0.8</priority>
438 | </url>
439 | </urlset>`;
440 |
441 | const sitemapPath = path.join(testDir, "sitemap.xml");
442 | await fs.writeFile(sitemapPath, xml);
443 |
444 | const urls = await listSitemapUrls(sitemapPath);
445 |
446 | expect(urls).toHaveLength(2);
447 | expect(urls[0].loc).toBe("https://example.com/page1.html");
448 | expect(urls[1].loc).toBe("https://example.com/page2.html");
449 | });
450 | });
451 |
452 | describe("edge cases", () => {
453 | it("should handle deeply nested directory structures", async () => {
454 | const deepPath = path.join(docsDir, "a", "b", "c", "d");
455 | await fs.mkdir(deepPath, { recursive: true });
456 | await fs.writeFile(path.join(deepPath, "deep.md"), "# Deep Page");
457 |
458 | const result = await generateSitemap({
459 | baseUrl: "https://example.com",
460 | docsPath: docsDir,
461 | useGitHistory: false,
462 | });
463 |
464 | expect(result.urls).toHaveLength(1);
465 | expect(result.urls[0].loc).toContain("a/b/c/d/deep.html");
466 | });
467 |
468 | it("should handle files with special characters in names", async () => {
469 | await fs.writeFile(path.join(docsDir, "my-page-2024.md"), "# Page");
470 |
471 | const result = await generateSitemap({
472 | baseUrl: "https://example.com",
473 | docsPath: docsDir,
474 | useGitHistory: false,
475 | });
476 |
477 | expect(result.urls).toHaveLength(1);
478 | expect(result.urls[0].loc).toContain("my-page-2024.html");
479 | });
480 |
481 | it("should handle index.html correctly", async () => {
482 | await fs.writeFile(path.join(docsDir, "index.md"), "# Home");
483 |
484 | const result = await generateSitemap({
485 | baseUrl: "https://example.com",
486 | docsPath: docsDir,
487 | useGitHistory: false,
488 | });
489 |
490 | expect(result.urls[0].loc).toBe("https://example.com/");
491 | });
492 |
493 | it("should exclude directories matching exclusion patterns", async () => {
494 | // Create directory structure with excluded dirs
495 | await fs.mkdir(path.join(docsDir, "node_modules"), { recursive: true });
496 | await fs.mkdir(path.join(docsDir, "valid"), { recursive: true });
497 | await fs.writeFile(
498 | path.join(docsDir, "node_modules", "package.md"),
499 | "# Should be excluded",
500 | );
501 | await fs.writeFile(
502 | path.join(docsDir, "valid", "page.md"),
503 | "# Valid Page",
504 | );
505 |
506 | const result = await generateSitemap({
507 | baseUrl: "https://example.com",
508 | docsPath: docsDir,
509 | useGitHistory: false,
510 | });
511 |
512 | // Should only include valid directory, not node_modules
513 | expect(result.urls).toHaveLength(1);
514 | expect(result.urls[0].loc).toContain("valid/page");
515 | });
516 |
517 | it("should handle directory scan errors gracefully", async () => {
518 | // Create a valid docs directory
519 | await fs.writeFile(path.join(docsDir, "valid.md"), "# Valid");
520 |
521 | const result = await generateSitemap({
522 | baseUrl: "https://example.com",
523 | docsPath: docsDir,
524 | useGitHistory: false,
525 | });
526 |
527 | // Should succeed despite potential permission issues
528 | expect(result.urls.length).toBeGreaterThanOrEqual(1);
529 | });
530 |
531 | it("should categorize explanation pages correctly", async () => {
532 | await fs.mkdir(path.join(docsDir, "explanation"), { recursive: true });
533 | await fs.writeFile(
534 | path.join(docsDir, "explanation", "concepts.md"),
535 | "# Concepts",
536 | );
537 |
538 | const result = await generateSitemap({
539 | baseUrl: "https://example.com",
540 | docsPath: docsDir,
541 | useGitHistory: false,
542 | });
543 |
544 | expect(result.stats.byCategory).toHaveProperty("explanation");
545 | expect(result.stats.byCategory.explanation).toBeGreaterThan(0);
546 | });
547 |
548 | it("should fall back to file system date when git fails", async () => {
549 | await fs.writeFile(path.join(docsDir, "no-git.md"), "# No Git");
550 |
551 | const result = await generateSitemap({
552 | baseUrl: "https://example.com",
553 | docsPath: docsDir,
554 | useGitHistory: true, // Try git but will fall back
555 | });
556 |
557 | // Should still have a lastmod date from file system
558 | expect(result.urls[0].lastmod).toBeDefined();
559 | expect(result.urls[0].lastmod).toMatch(/^\d{4}-\d{2}-\d{2}$/);
560 | });
561 |
562 | it("should handle files without extensions", async () => {
563 | await fs.writeFile(path.join(docsDir, "README"), "# Readme");
564 |
565 | const result = await generateSitemap({
566 | baseUrl: "https://example.com",
567 | docsPath: docsDir,
568 | includePatterns: ["**/*"], // Include all files
569 | useGitHistory: false,
570 | });
571 |
572 | // Should handle extensionless files
573 | expect(result.urls.length).toBeGreaterThanOrEqual(0);
574 | });
575 |
576 | it("should handle empty git timestamp", async () => {
577 | // Create file and generate sitemap with git enabled
578 | await fs.writeFile(path.join(docsDir, "test.md"), "# Test");
579 |
580 | const result = await generateSitemap({
581 | baseUrl: "https://example.com",
582 | docsPath: docsDir,
583 | useGitHistory: true,
584 | });
585 |
586 | // Should have valid dates even if git returns empty
587 | expect(result.urls[0].lastmod).toBeDefined();
588 | });
589 |
590 | it("should handle files in deeply excluded paths", async () => {
591 | await fs.mkdir(path.join(docsDir, ".git", "objects"), {
592 | recursive: true,
593 | });
594 | await fs.writeFile(
595 | path.join(docsDir, ".git", "objects", "file.md"),
596 | "# Git Object",
597 | );
598 | await fs.writeFile(path.join(docsDir, "valid.md"), "# Valid");
599 |
600 | const result = await generateSitemap({
601 | baseUrl: "https://example.com",
602 | docsPath: docsDir,
603 | useGitHistory: false,
604 | });
605 |
606 | // Should exclude .git directory
607 | expect(result.urls).toHaveLength(1);
608 | expect(result.urls[0].loc).not.toContain(".git");
609 | });
610 |
611 | it("should extract title from HTML title tag", async () => {
612 | const htmlContent = `<!DOCTYPE html>
613 | <html>
614 | <head>
615 | <title>HTML Page Title</title>
616 | </head>
617 | <body>
618 | <h1>Different Heading</h1>
619 | </body>
620 | </html>`;
621 |
622 | await fs.writeFile(path.join(docsDir, "page.html"), htmlContent);
623 |
624 | const result = await generateSitemap({
625 | baseUrl: "https://example.com",
626 | docsPath: docsDir,
627 | includePatterns: ["**/*.html"],
628 | useGitHistory: false,
629 | });
630 |
631 | expect(result.urls[0].title).toBe("HTML Page Title");
632 | });
633 |
634 | it("should handle files with no extractable title", async () => {
635 | await fs.writeFile(path.join(docsDir, "notitle.md"), "Just content");
636 |
637 | const result = await generateSitemap({
638 | baseUrl: "https://example.com",
639 | docsPath: docsDir,
640 | useGitHistory: false,
641 | });
642 |
643 | expect(result.urls[0].title).toBeUndefined();
644 | });
645 |
646 | it("should handle inaccessible files gracefully", async () => {
647 | await fs.writeFile(path.join(docsDir, "readable.md"), "# Readable");
648 |
649 | const result = await generateSitemap({
650 | baseUrl: "https://example.com",
651 | docsPath: docsDir,
652 | useGitHistory: false,
653 | });
654 |
655 | // Should still process readable files
656 | expect(result.urls.length).toBeGreaterThan(0);
657 | });
658 | });
659 |
660 | describe("validateSitemap - additional validations", () => {
661 | it("should detect empty sitemap", async () => {
662 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
663 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
664 | </urlset>`;
665 |
666 | const sitemapPath = path.join(testDir, "sitemap.xml");
667 | await fs.writeFile(sitemapPath, xml);
668 |
669 | const result = await validateSitemap(sitemapPath);
670 |
671 | expect(result.valid).toBe(true);
672 | expect(result.warnings.some((w) => w.includes("no URLs"))).toBe(true);
673 | });
674 |
675 | it("should detect URL exceeding 2048 characters", async () => {
676 | const longUrl = `https://example.com/${"a".repeat(2100)}`;
677 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
678 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
679 | <url>
680 | <loc>${longUrl}</loc>
681 | </url>
682 | </urlset>`;
683 |
684 | const sitemapPath = path.join(testDir, "sitemap.xml");
685 | await fs.writeFile(sitemapPath, xml);
686 |
687 | const result = await validateSitemap(sitemapPath);
688 |
689 | expect(result.valid).toBe(false);
690 | expect(result.errors.some((e) => e.includes("exceeds 2048"))).toBe(true);
691 | });
692 |
693 | it("should warn about invalid lastmod format", async () => {
694 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
695 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
696 | <url>
697 | <loc>https://example.com/page.html</loc>
698 | <lastmod>invalid-date</lastmod>
699 | </url>
700 | </urlset>`;
701 |
702 | const sitemapPath = path.join(testDir, "sitemap.xml");
703 | await fs.writeFile(sitemapPath, xml);
704 |
705 | const result = await validateSitemap(sitemapPath);
706 |
707 | expect(result.warnings.some((w) => w.includes("Invalid lastmod"))).toBe(
708 | true,
709 | );
710 | });
711 |
712 | it("should detect sitemap with more than 50,000 URLs", async () => {
713 | // Create sitemap XML with >50,000 URLs
714 | const urls = Array.from(
715 | { length: 50001 },
716 | (_, i) => ` <url>
717 | <loc>https://example.com/page${i}.html</loc>
718 | <lastmod>2025-01-01</lastmod>
719 | </url>`,
720 | ).join("\n");
721 |
722 | const xml = `<?xml version="1.0" encoding="UTF-8"?>
723 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
724 | ${urls}
725 | </urlset>`;
726 |
727 | const sitemapPath = path.join(testDir, "large-sitemap.xml");
728 | await fs.writeFile(sitemapPath, xml);
729 |
730 | const result = await validateSitemap(sitemapPath);
731 |
732 | expect(result.valid).toBe(false);
733 | expect(result.errors.some((e) => e.includes("50,000"))).toBe(true);
734 | });
735 |
736 | it("should handle malformed XML gracefully", async () => {
737 | // The regex-based parser is lenient and extracts data where possible
738 | // This tests that the parser doesn't crash on malformed XML
739 | const malformedXml = `<?xml version="1.0"?>
740 | <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
741 | <url>
742 | <loc>https://example.com</loc>
743 | </url>
744 | <!-- Missing closing urlset tag`;
745 |
746 | const sitemapPath = path.join(testDir, "malformed.xml");
747 | await fs.writeFile(sitemapPath, malformedXml);
748 |
749 | // Should parse successfully despite malformation (regex-based parsing)
750 | const result = await validateSitemap(sitemapPath);
751 | expect(result).toBeDefined();
752 | expect(result.urlCount).toBe(1);
753 | });
754 | });
755 |
756 | describe("Edge cases", () => {
757 | it("should handle excluded directories", async () => {
758 | // Create structure with node_modules
759 | await fs.mkdir(path.join(testDir, "node_modules"), { recursive: true });
760 | await fs.writeFile(
761 | path.join(testDir, "node_modules", "package.md"),
762 | "# Should be excluded",
763 | );
764 | await fs.writeFile(path.join(testDir, "included.md"), "# Included");
765 |
766 | const result = await generateSitemap({
767 | baseUrl: "https://example.com",
768 | docsPath: testDir,
769 | includePatterns: ["**/*.md"],
770 | useGitHistory: false,
771 | });
772 |
773 | expect(result.urls.some((u) => u.loc.includes("node_modules"))).toBe(
774 | false,
775 | );
776 | expect(result.urls.some((u) => u.loc.includes("included"))).toBe(true);
777 | });
778 |
779 | it("should handle directory scan errors gracefully", async () => {
780 | // Test with a path that has permission issues or doesn't exist
781 | const result = await generateSitemap({
782 | baseUrl: "https://example.com",
783 | docsPath: path.join(testDir, "nonexistent"),
784 | includePatterns: ["**/*.md"],
785 | useGitHistory: false,
786 | });
787 |
788 | expect(result.urls).toEqual([]);
789 | });
790 |
791 | it("should use git timestamp when available", async () => {
792 | // Initialize git and create a committed file
793 | await fs.writeFile(path.join(testDir, "test.md"), "# Test");
794 |
795 | try {
796 | const { execSync } = require("child_process");
797 | execSync("git init", { cwd: testDir, stdio: "ignore" });
798 | execSync("git config user.email '[email protected]'", {
799 | cwd: testDir,
800 | stdio: "ignore",
801 | });
802 | execSync("git config user.name 'Test'", {
803 | cwd: testDir,
804 | stdio: "ignore",
805 | });
806 | execSync("git add test.md", { cwd: testDir, stdio: "ignore" });
807 | execSync("git commit -m 'test'", { cwd: testDir, stdio: "ignore" });
808 |
809 | const result = await generateSitemap({
810 | baseUrl: "https://example.com",
811 | docsPath: testDir,
812 | includePatterns: ["**/*.md"],
813 | useGitHistory: true,
814 | });
815 |
816 | expect(result.urls.length).toBe(1);
817 | expect(result.urls[0].lastmod).toMatch(/\d{4}-\d{2}-\d{2}/);
818 | } catch (error) {
819 | // Git might not be available in test environment, skip
820 | console.log("Git test skipped:", error);
821 | }
822 | });
823 |
824 | it("should use current date when file doesn't exist", async () => {
825 | // This tests the getFileLastModified error path
826 | // We'll indirectly test this by ensuring dates are always returned
827 | const result = await generateSitemap({
828 | baseUrl: "https://example.com",
829 | docsPath: testDir,
830 | includePatterns: ["**/*.md"],
831 | useGitHistory: false,
832 | });
833 |
834 | // Even with no files, function should not crash
835 | expect(result).toBeDefined();
836 | expect(Array.isArray(result.urls)).toBe(true);
837 | });
838 | });
839 | });
840 |
```
--------------------------------------------------------------------------------
/src/memory/kg-health.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Knowledge Graph Health Monitoring Module
3 | * Implements Phase 2: KG Health Tracking
4 | *
5 | * Provides comprehensive health monitoring, issue detection, and trend analysis
6 | * for the DocuMCP knowledge graph to ensure data quality and performance.
7 | */
8 |
9 | import { promises as fs } from "fs";
10 | import { join } from "path";
11 | import KnowledgeGraph, { GraphNode, GraphEdge } from "./knowledge-graph.js";
12 | import { KGStorage } from "./kg-storage.js";
13 |
14 | // ============================================================================
15 | // Health Metrics Schema
16 | // ============================================================================
17 |
18 | export interface KGHealthMetrics {
19 | timestamp: string;
20 | overallHealth: number; // 0-100 score
21 | dataQuality: DataQualityMetrics;
22 | structureHealth: StructureHealthMetrics;
23 | performance: PerformanceMetrics;
24 | trends: HealthTrends;
25 | issues: HealthIssue[];
26 | recommendations: HealthRecommendation[];
27 | }
28 |
29 | export interface DataQualityMetrics {
30 | score: number; // 0-100
31 | staleNodeCount: number; // nodes not updated in 30+ days
32 | orphanedEdgeCount: number;
33 | duplicateCount: number;
34 | confidenceAverage: number;
35 | completenessScore: number; // % of expected relationships present
36 | totalNodes: number;
37 | totalEdges: number;
38 | }
39 |
40 | export interface StructureHealthMetrics {
41 | score: number; // 0-100
42 | isolatedNodeCount: number; // nodes with no edges
43 | clusteringCoefficient: number;
44 | averagePathLength: number;
45 | densityScore: number;
46 | connectedComponents: number;
47 | }
48 |
49 | export interface PerformanceMetrics {
50 | score: number; // 0-100
51 | avgQueryTime: number; // ms
52 | storageSize: number; // bytes
53 | growthRate: number; // bytes/day
54 | indexEfficiency: number;
55 | }
56 |
57 | export interface HealthTrends {
58 | healthTrend: "improving" | "stable" | "degrading";
59 | nodeGrowthRate: number; // nodes/day
60 | edgeGrowthRate: number; // edges/day
61 | errorRate: number; // errors/operations (from last 100 operations)
62 | qualityTrend: "improving" | "stable" | "degrading";
63 | }
64 |
65 | export interface HealthIssue {
66 | id: string;
67 | severity: "critical" | "high" | "medium" | "low";
68 | category: "integrity" | "performance" | "quality" | "structure";
69 | description: string;
70 | affectedEntities: string[];
71 | remediation: string;
72 | detectedAt: string;
73 | autoFixable: boolean;
74 | }
75 |
76 | export interface HealthRecommendation {
77 | id: string;
78 | priority: "high" | "medium" | "low";
79 | action: string;
80 | expectedImpact: number; // health score increase (0-100)
81 | effort: "low" | "medium" | "high";
82 | category: string;
83 | }
84 |
85 | export interface HealthHistory {
86 | timestamp: string;
87 | overallHealth: number;
88 | dataQuality: number;
89 | structureHealth: number;
90 | performance: number;
91 | nodeCount: number;
92 | edgeCount: number;
93 | }
94 |
95 | // ============================================================================
96 | // Health Monitoring Class
97 | // ============================================================================
98 |
99 | export class KGHealthMonitor {
100 | private storageDir: string;
101 | private historyFilePath: string;
102 | private issueDetectors: IssueDetector[];
103 | private performanceTracking: PerformanceTracker;
104 |
105 | constructor(storageDir?: string) {
106 | this.storageDir = storageDir || `${process.cwd()}/.documcp/memory`;
107 | this.historyFilePath = join(this.storageDir, "health-history.jsonl");
108 | this.issueDetectors = createIssueDetectors();
109 | this.performanceTracking = new PerformanceTracker();
110 | }
111 |
112 | /**
113 | * Calculate comprehensive health metrics
114 | */
115 | async calculateHealth(
116 | kg: KnowledgeGraph,
117 | storage: KGStorage,
118 | ): Promise<KGHealthMetrics> {
119 | const timestamp = new Date().toISOString();
120 |
121 | // Calculate component metrics
122 | const dataQuality = await this.calculateDataQuality(kg, storage);
123 | const structureHealth = await this.calculateStructureHealth(kg);
124 | const performance = await this.calculatePerformance(storage);
125 |
126 | // Calculate overall health (weighted average)
127 | const overallHealth = Math.round(
128 | dataQuality.score * 0.4 +
129 | structureHealth.score * 0.3 +
130 | performance.score * 0.3,
131 | );
132 |
133 | // Detect issues
134 | const issues = await this.detectIssues(kg, {
135 | dataQuality,
136 | structureHealth,
137 | performance,
138 | });
139 |
140 | // Generate recommendations
141 | const recommendations = this.generateRecommendations(issues, {
142 | dataQuality,
143 | structureHealth,
144 | performance,
145 | });
146 |
147 | // Analyze trends
148 | const trends = await this.analyzeTrends(overallHealth);
149 |
150 | const metrics: KGHealthMetrics = {
151 | timestamp,
152 | overallHealth,
153 | dataQuality,
154 | structureHealth,
155 | performance,
156 | trends,
157 | issues,
158 | recommendations,
159 | };
160 |
161 | // Track history
162 | await this.trackHealthHistory(metrics);
163 |
164 | return metrics;
165 | }
166 |
167 | /**
168 | * Calculate data quality metrics
169 | */
170 | private async calculateDataQuality(
171 | kg: KnowledgeGraph,
172 | storage: KGStorage,
173 | ): Promise<DataQualityMetrics> {
174 | await kg.getStatistics();
175 | const integrity = await storage.verifyIntegrity();
176 |
177 | const now = new Date();
178 | const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
179 |
180 | // Count stale nodes
181 | const allNodes = await kg.getAllNodes();
182 | const staleNodeCount = allNodes.filter((node) => {
183 | const lastUpdated = new Date(node.lastUpdated);
184 | return lastUpdated < thirtyDaysAgo;
185 | }).length;
186 |
187 | // Get orphaned edges from integrity check
188 | const orphanedEdgeCount = integrity.warnings.filter((w) =>
189 | w.includes("missing"),
190 | ).length;
191 |
192 | // Get duplicate count from integrity check
193 | const duplicateCount = integrity.errors.filter((e) =>
194 | e.includes("Duplicate"),
195 | ).length;
196 |
197 | // Calculate average confidence
198 | const allEdges = await kg.getAllEdges();
199 | const confidenceAverage =
200 | allEdges.length > 0
201 | ? allEdges.reduce((sum, edge) => sum + edge.confidence, 0) /
202 | allEdges.length
203 | : 1.0;
204 |
205 | // Calculate completeness (% of projects with expected relationships)
206 | const completenessScore = this.calculateCompleteness(allNodes, allEdges);
207 |
208 | // Calculate data quality score (0-100)
209 | const stalePercentage =
210 | (staleNodeCount / Math.max(allNodes.length, 1)) * 100;
211 | const orphanPercentage =
212 | (orphanedEdgeCount / Math.max(allEdges.length, 1)) * 100;
213 | const qualityDeductions =
214 | stalePercentage * 0.3 + orphanPercentage * 0.5 + duplicateCount * 10;
215 |
216 | const score = Math.max(
217 | 0,
218 | Math.min(100, 100 - qualityDeductions + (completenessScore - 0.5) * 50),
219 | );
220 |
221 | return {
222 | score: Math.round(score),
223 | staleNodeCount,
224 | orphanedEdgeCount,
225 | duplicateCount,
226 | confidenceAverage,
227 | completenessScore,
228 | totalNodes: allNodes.length,
229 | totalEdges: allEdges.length,
230 | };
231 | }
232 |
233 | /**
234 | * Calculate structure health metrics
235 | */
236 | private async calculateStructureHealth(
237 | kg: KnowledgeGraph,
238 | ): Promise<StructureHealthMetrics> {
239 | await kg.getStatistics();
240 | const allNodes = await kg.getAllNodes();
241 | const allEdges = await kg.getAllEdges();
242 |
243 | // Count isolated nodes (no edges)
244 | const nodeConnections = new Map<string, number>();
245 | for (const edge of allEdges) {
246 | nodeConnections.set(
247 | edge.source,
248 | (nodeConnections.get(edge.source) || 0) + 1,
249 | );
250 | nodeConnections.set(
251 | edge.target,
252 | (nodeConnections.get(edge.target) || 0) + 1,
253 | );
254 | }
255 |
256 | const isolatedNodeCount = allNodes.filter(
257 | (node) => !nodeConnections.has(node.id),
258 | ).length;
259 |
260 | // Calculate clustering coefficient (simplified)
261 | const clusteringCoefficient = this.calculateClusteringCoefficient(
262 | allNodes,
263 | allEdges,
264 | );
265 |
266 | // Calculate average path length (simplified - using BFS on sample)
267 | const averagePathLength = this.calculateAveragePathLength(
268 | allNodes,
269 | allEdges,
270 | );
271 |
272 | // Calculate density score
273 | const maxPossibleEdges = (allNodes.length * (allNodes.length - 1)) / 2;
274 | const densityScore =
275 | maxPossibleEdges > 0 ? allEdges.length / maxPossibleEdges : 0;
276 |
277 | // Count connected components
278 | const connectedComponents = this.countConnectedComponents(
279 | allNodes,
280 | allEdges,
281 | );
282 |
283 | // Calculate structure health score
284 | const isolatedPercentage =
285 | (isolatedNodeCount / Math.max(allNodes.length, 1)) * 100;
286 | const score = Math.max(
287 | 0,
288 | Math.min(
289 | 100,
290 | 100 -
291 | isolatedPercentage * 0.5 +
292 | clusteringCoefficient * 20 -
293 | (connectedComponents - 1) * 5,
294 | ),
295 | );
296 |
297 | return {
298 | score: Math.round(score),
299 | isolatedNodeCount,
300 | clusteringCoefficient,
301 | averagePathLength,
302 | densityScore,
303 | connectedComponents,
304 | };
305 | }
306 |
307 | /**
308 | * Calculate performance metrics
309 | */
310 | private async calculatePerformance(
311 | storage: KGStorage,
312 | ): Promise<PerformanceMetrics> {
313 | const storageStats = await storage.getStatistics();
314 |
315 | // Get average query time from performance tracker
316 | const avgQueryTime = this.performanceTracking.getAverageQueryTime();
317 |
318 | // Calculate storage size
319 | const storageSize =
320 | storageStats.fileSize.entities + storageStats.fileSize.relationships;
321 |
322 | // Calculate growth rate (bytes/day) from history
323 | const growthRate = await this.calculateGrowthRate();
324 |
325 | // Index efficiency (placeholder - would need actual indexing metrics)
326 | const indexEfficiency = 0.8;
327 |
328 | // Calculate performance score
329 | const queryScore =
330 | avgQueryTime < 10 ? 100 : Math.max(0, 100 - avgQueryTime);
331 | const sizeScore =
332 | storageSize < 10 * 1024 * 1024
333 | ? 100
334 | : Math.max(0, 100 - storageSize / (1024 * 1024));
335 | const score = Math.round(
336 | queryScore * 0.5 + sizeScore * 0.3 + indexEfficiency * 100 * 0.2,
337 | );
338 |
339 | return {
340 | score,
341 | avgQueryTime,
342 | storageSize,
343 | growthRate,
344 | indexEfficiency,
345 | };
346 | }
347 |
348 | /**
349 | * Detect issues in the knowledge graph
350 | */
351 | private async detectIssues(
352 | kg: KnowledgeGraph,
353 | metrics: {
354 | dataQuality: DataQualityMetrics;
355 | structureHealth: StructureHealthMetrics;
356 | performance: PerformanceMetrics;
357 | },
358 | ): Promise<HealthIssue[]> {
359 | const issues: HealthIssue[] = [];
360 |
361 | for (const detector of this.issueDetectors) {
362 | const detectedIssues = await detector.detect(kg, metrics);
363 | issues.push(...detectedIssues);
364 | }
365 |
366 | // Sort by severity
367 | issues.sort((a, b) => {
368 | const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
369 | return severityOrder[a.severity] - severityOrder[b.severity];
370 | });
371 |
372 | return issues;
373 | }
374 |
375 | /**
376 | * Generate recommendations based on issues and metrics
377 | */
378 | private generateRecommendations(
379 | issues: HealthIssue[],
380 | metrics: {
381 | dataQuality: DataQualityMetrics;
382 | structureHealth: StructureHealthMetrics;
383 | performance: PerformanceMetrics;
384 | },
385 | ): HealthRecommendation[] {
386 | const recommendations: HealthRecommendation[] = [];
387 |
388 | // Generate recommendations for critical/high severity issues
389 | for (const issue of issues.filter(
390 | (i) => i.severity === "critical" || i.severity === "high",
391 | )) {
392 | if (issue.autoFixable) {
393 | recommendations.push({
394 | id: `fix_${issue.id}`,
395 | priority: "high",
396 | action: issue.remediation,
397 | expectedImpact: issue.severity === "critical" ? 20 : 10,
398 | effort: "low",
399 | category: issue.category,
400 | });
401 | }
402 | }
403 |
404 | // Data quality recommendations
405 | if (metrics.dataQuality.score < 70) {
406 | if (metrics.dataQuality.staleNodeCount > 10) {
407 | recommendations.push({
408 | id: "refresh_stale_data",
409 | priority: "medium",
410 | action: `Re-analyze ${metrics.dataQuality.staleNodeCount} stale projects to refresh data`,
411 | expectedImpact: 15,
412 | effort: "medium",
413 | category: "data_quality",
414 | });
415 | }
416 |
417 | if (metrics.dataQuality.orphanedEdgeCount > 5) {
418 | recommendations.push({
419 | id: "cleanup_orphaned_edges",
420 | priority: "high",
421 | action: "Run automated cleanup to remove orphaned relationships",
422 | expectedImpact: 10,
423 | effort: "low",
424 | category: "data_quality",
425 | });
426 | }
427 | }
428 |
429 | // Structure health recommendations
430 | if (metrics.structureHealth.score < 70) {
431 | if (metrics.structureHealth.isolatedNodeCount > 0) {
432 | recommendations.push({
433 | id: "connect_isolated_nodes",
434 | priority: "medium",
435 | action: `Review and connect ${metrics.structureHealth.isolatedNodeCount} isolated nodes`,
436 | expectedImpact: 8,
437 | effort: "medium",
438 | category: "structure",
439 | });
440 | }
441 | }
442 |
443 | // Performance recommendations
444 | if (metrics.performance.score < 70) {
445 | if (metrics.performance.storageSize > 50 * 1024 * 1024) {
446 | recommendations.push({
447 | id: "optimize_storage",
448 | priority: "medium",
449 | action: "Archive or compress old knowledge graph data",
450 | expectedImpact: 12,
451 | effort: "high",
452 | category: "performance",
453 | });
454 | }
455 | }
456 |
457 | // Sort by priority and expected impact
458 | recommendations.sort((a, b) => {
459 | const priorityOrder = { high: 0, medium: 1, low: 2 };
460 | if (priorityOrder[a.priority] !== priorityOrder[b.priority]) {
461 | return priorityOrder[a.priority] - priorityOrder[b.priority];
462 | }
463 | return b.expectedImpact - a.expectedImpact;
464 | });
465 |
466 | return recommendations.slice(0, 5); // Top 5 recommendations
467 | }
468 |
469 | /**
470 | * Analyze trends from historical health data
471 | */
472 | private async analyzeTrends(currentHealth: number): Promise<HealthTrends> {
473 | const history = await this.getHealthHistory(7); // Last 7 days
474 |
475 | if (history.length < 2) {
476 | return {
477 | healthTrend: "stable",
478 | nodeGrowthRate: 0,
479 | edgeGrowthRate: 0,
480 | errorRate: 0,
481 | qualityTrend: "stable",
482 | };
483 | }
484 |
485 | // Calculate health trend
486 | const sevenDayAvg =
487 | history.reduce((sum, h) => sum + h.overallHealth, 0) / history.length;
488 | const healthDiff = currentHealth - sevenDayAvg;
489 |
490 | const healthTrend =
491 | healthDiff > 5 ? "improving" : healthDiff < -5 ? "degrading" : "stable";
492 |
493 | // Calculate growth rates
494 | const oldestEntry = history[history.length - 1];
495 | const newestEntry = history[0];
496 | const daysDiff = Math.max(
497 | 1,
498 | (new Date(newestEntry.timestamp).getTime() -
499 | new Date(oldestEntry.timestamp).getTime()) /
500 | (1000 * 60 * 60 * 24),
501 | );
502 |
503 | const nodeGrowthRate =
504 | (newestEntry.nodeCount - oldestEntry.nodeCount) / daysDiff;
505 | const edgeGrowthRate =
506 | (newestEntry.edgeCount - oldestEntry.edgeCount) / daysDiff;
507 |
508 | // Quality trend
509 | const qualityAvg =
510 | history.reduce((sum, h) => sum + h.dataQuality, 0) / history.length;
511 | const qualityDiff = history[0].dataQuality - qualityAvg;
512 |
513 | const qualityTrend =
514 | qualityDiff > 5 ? "improving" : qualityDiff < -5 ? "degrading" : "stable";
515 |
516 | return {
517 | healthTrend,
518 | nodeGrowthRate: Math.round(nodeGrowthRate * 10) / 10,
519 | edgeGrowthRate: Math.round(edgeGrowthRate * 10) / 10,
520 | errorRate: 0, // TODO: Track from operations log
521 | qualityTrend,
522 | };
523 | }
524 |
525 | /**
526 | * Track health history to persistent storage
527 | */
528 | private async trackHealthHistory(metrics: KGHealthMetrics): Promise<void> {
529 | const historyEntry: HealthHistory = {
530 | timestamp: metrics.timestamp,
531 | overallHealth: metrics.overallHealth,
532 | dataQuality: metrics.dataQuality.score,
533 | structureHealth: metrics.structureHealth.score,
534 | performance: metrics.performance.score,
535 | nodeCount: metrics.dataQuality.totalNodes,
536 | edgeCount: metrics.dataQuality.totalEdges,
537 | };
538 |
539 | try {
540 | await fs.appendFile(
541 | this.historyFilePath,
542 | JSON.stringify(historyEntry) + "\n",
543 | "utf-8",
544 | );
545 |
546 | // Keep only last 90 days of history
547 | await this.pruneHistoryFile(90);
548 | } catch (error) {
549 | console.warn("Failed to track health history:", error);
550 | }
551 | }
552 |
553 | /**
554 | * Get health history for the last N days
555 | */
556 | private async getHealthHistory(days: number): Promise<HealthHistory[]> {
557 | try {
558 | const content = await fs.readFile(this.historyFilePath, "utf-8");
559 | const lines = content.trim().split("\n");
560 |
561 | const cutoffDate = new Date();
562 | cutoffDate.setDate(cutoffDate.getDate() - days);
563 |
564 | const history: HealthHistory[] = [];
565 | for (const line of lines) {
566 | if (line.trim()) {
567 | const entry = JSON.parse(line) as HealthHistory;
568 | if (new Date(entry.timestamp) >= cutoffDate) {
569 | history.push(entry);
570 | }
571 | }
572 | }
573 |
574 | return history.reverse(); // Most recent first
575 | } catch {
576 | return [];
577 | }
578 | }
579 |
580 | /**
581 | * Prune history file to keep only last N days
582 | */
583 | private async pruneHistoryFile(days: number): Promise<void> {
584 | try {
585 | const history = await this.getHealthHistory(days);
586 | const content = history.map((h) => JSON.stringify(h)).join("\n") + "\n";
587 | await fs.writeFile(this.historyFilePath, content, "utf-8");
588 | } catch (error) {
589 | console.warn("Failed to prune history file:", error);
590 | }
591 | }
592 |
593 | // Helper methods
594 |
595 | private calculateCompleteness(
596 | nodes: GraphNode[],
597 | edges: GraphEdge[],
598 | ): number {
599 | const projectNodes = nodes.filter((n) => n.type === "project");
600 | if (projectNodes.length === 0) return 1.0;
601 |
602 | let totalExpected = 0;
603 | let totalFound = 0;
604 |
605 | for (const project of projectNodes) {
606 | // Expected relationships for each project:
607 | // 1. At least one technology relationship
608 | // 2. Documentation relationship (if hasDocs = true)
609 | // 3. Configuration relationship (if deployed)
610 |
611 | totalExpected += 1; // Technology
612 |
613 | const projectEdges = edges.filter((e) => e.source === project.id);
614 |
615 | if (projectEdges.some((e) => e.type === "project_uses_technology")) {
616 | totalFound += 1;
617 | }
618 |
619 | if (project.properties.hasDocs) {
620 | totalExpected += 1;
621 | if (
622 | projectEdges.some(
623 | (e) =>
624 | e.type === "depends_on" &&
625 | nodes.find((n) => n.id === e.target)?.type ===
626 | "documentation_section",
627 | )
628 | ) {
629 | totalFound += 1;
630 | }
631 | }
632 | }
633 |
634 | return totalExpected > 0 ? totalFound / totalExpected : 1.0;
635 | }
636 |
637 | private calculateClusteringCoefficient(
638 | nodes: GraphNode[],
639 | edges: GraphEdge[],
640 | ): number {
641 | // Simplified clustering coefficient calculation
642 | if (nodes.length < 3) return 0;
643 |
644 | const adjacency = new Map<string, Set<string>>();
645 | for (const edge of edges) {
646 | if (!adjacency.has(edge.source)) {
647 | adjacency.set(edge.source, new Set());
648 | }
649 | adjacency.get(edge.source)!.add(edge.target);
650 | }
651 |
652 | let totalCoefficient = 0;
653 | let nodeCount = 0;
654 |
655 | for (const node of nodes.slice(0, 100)) {
656 | // Sample first 100 nodes
657 | const neighbors = adjacency.get(node.id);
658 | if (!neighbors || neighbors.size < 2) continue;
659 |
660 | const neighborArray = Array.from(neighbors);
661 | let triangles = 0;
662 | const possibleTriangles =
663 | (neighborArray.length * (neighborArray.length - 1)) / 2;
664 |
665 | for (let i = 0; i < neighborArray.length; i++) {
666 | for (let j = i + 1; j < neighborArray.length; j++) {
667 | const n1Neighbors = adjacency.get(neighborArray[i]);
668 | if (n1Neighbors?.has(neighborArray[j])) {
669 | triangles++;
670 | }
671 | }
672 | }
673 |
674 | if (possibleTriangles > 0) {
675 | totalCoefficient += triangles / possibleTriangles;
676 | nodeCount++;
677 | }
678 | }
679 |
680 | return nodeCount > 0 ? totalCoefficient / nodeCount : 0;
681 | }
682 |
683 | private calculateAveragePathLength(
684 | nodes: GraphNode[],
685 | edges: GraphEdge[],
686 | ): number {
687 | // Simplified using sample BFS
688 | if (nodes.length === 0) return 0;
689 |
690 | const adjacency = new Map<string, string[]>();
691 | for (const edge of edges) {
692 | if (!adjacency.has(edge.source)) {
693 | adjacency.set(edge.source, []);
694 | }
695 | adjacency.get(edge.source)!.push(edge.target);
696 | }
697 |
698 | // Sample 10 random nodes for BFS
699 | const sampleSize = Math.min(10, nodes.length);
700 | let totalPathLength = 0;
701 | let pathCount = 0;
702 |
703 | for (let i = 0; i < sampleSize; i++) {
704 | const startNode = nodes[i];
705 | const distances = new Map<string, number>();
706 | const queue = [startNode.id];
707 | distances.set(startNode.id, 0);
708 |
709 | while (queue.length > 0) {
710 | const current = queue.shift()!;
711 | const currentDist = distances.get(current)!;
712 |
713 | const neighbors = adjacency.get(current) || [];
714 | for (const neighbor of neighbors) {
715 | if (!distances.has(neighbor)) {
716 | distances.set(neighbor, currentDist + 1);
717 | queue.push(neighbor);
718 | }
719 | }
720 | }
721 |
722 | for (const dist of distances.values()) {
723 | if (dist > 0) {
724 | totalPathLength += dist;
725 | pathCount++;
726 | }
727 | }
728 | }
729 |
730 | return pathCount > 0 ? totalPathLength / pathCount : 0;
731 | }
732 |
733 | private countConnectedComponents(
734 | nodes: GraphNode[],
735 | edges: GraphEdge[],
736 | ): number {
737 | if (nodes.length === 0) return 0;
738 |
739 | const adjacency = new Map<string, Set<string>>();
740 | for (const edge of edges) {
741 | if (!adjacency.has(edge.source)) {
742 | adjacency.set(edge.source, new Set());
743 | }
744 | if (!adjacency.has(edge.target)) {
745 | adjacency.set(edge.target, new Set());
746 | }
747 | adjacency.get(edge.source)!.add(edge.target);
748 | adjacency.get(edge.target)!.add(edge.source);
749 | }
750 |
751 | const visited = new Set<string>();
752 | let components = 0;
753 |
754 | for (const node of nodes) {
755 | if (!visited.has(node.id)) {
756 | components++;
757 | const queue = [node.id];
758 |
759 | while (queue.length > 0) {
760 | const current = queue.shift()!;
761 | if (visited.has(current)) continue;
762 |
763 | visited.add(current);
764 | const neighbors = adjacency.get(current) || new Set();
765 | for (const neighbor of neighbors) {
766 | if (!visited.has(neighbor)) {
767 | queue.push(neighbor);
768 | }
769 | }
770 | }
771 | }
772 | }
773 |
774 | return components;
775 | }
776 |
777 | private async calculateGrowthRate(): Promise<number> {
778 | const history = await this.getHealthHistory(30);
779 | if (history.length < 2) return 0;
780 |
781 | // Calculate storage size growth (simplified)
782 | return 1024; // Placeholder: 1KB/day
783 | }
784 | }
785 |
786 | // ============================================================================
787 | // Issue Detectors
788 | // ============================================================================
789 |
790 | interface IssueDetector {
791 | name: string;
792 | detect(
793 | kg: KnowledgeGraph,
794 | metrics: {
795 | dataQuality: DataQualityMetrics;
796 | structureHealth: StructureHealthMetrics;
797 | performance: PerformanceMetrics;
798 | },
799 | ): Promise<HealthIssue[]>;
800 | }
801 |
802 | function createIssueDetectors(): IssueDetector[] {
803 | return [
804 | {
805 | name: "orphaned_edges",
806 | async detect(kg, metrics) {
807 | if (metrics.dataQuality.orphanedEdgeCount > 10) {
808 | return [
809 | {
810 | id: "orphaned_edges_high",
811 | severity: "high",
812 | category: "integrity",
813 | description: `Found ${metrics.dataQuality.orphanedEdgeCount} orphaned relationships`,
814 | affectedEntities: [],
815 | remediation: "Run kg.removeOrphanedEdges() to clean up",
816 | detectedAt: new Date().toISOString(),
817 | autoFixable: true,
818 | },
819 | ];
820 | }
821 | return [];
822 | },
823 | },
824 | {
825 | name: "stale_data",
826 | async detect(kg, metrics) {
827 | if (metrics.dataQuality.staleNodeCount > 20) {
828 | return [
829 | {
830 | id: "stale_data_high",
831 | severity: "medium",
832 | category: "quality",
833 | description: `${metrics.dataQuality.staleNodeCount} nodes haven't been updated in 30+ days`,
834 | affectedEntities: [],
835 | remediation: "Re-analyze stale projects to refresh data",
836 | detectedAt: new Date().toISOString(),
837 | autoFixable: false,
838 | },
839 | ];
840 | }
841 | return [];
842 | },
843 | },
844 | {
845 | name: "low_completeness",
846 | async detect(kg, metrics) {
847 | if (metrics.dataQuality.completenessScore < 0.7) {
848 | return [
849 | {
850 | id: "low_completeness",
851 | severity: "high",
852 | category: "quality",
853 | description: `Completeness score is ${Math.round(
854 | metrics.dataQuality.completenessScore * 100,
855 | )}%`,
856 | affectedEntities: [],
857 | remediation: "Review projects for missing relationships",
858 | detectedAt: new Date().toISOString(),
859 | autoFixable: false,
860 | },
861 | ];
862 | }
863 | return [];
864 | },
865 | },
866 | {
867 | name: "isolated_nodes",
868 | async detect(kg, metrics) {
869 | const threshold = metrics.structureHealth.isolatedNodeCount;
870 | if (threshold > metrics.dataQuality.totalNodes * 0.05) {
871 | return [
872 | {
873 | id: "isolated_nodes_high",
874 | severity: "medium",
875 | category: "structure",
876 | description: `${threshold} nodes are isolated (no connections)`,
877 | affectedEntities: [],
878 | remediation: "Review and connect isolated nodes",
879 | detectedAt: new Date().toISOString(),
880 | autoFixable: false,
881 | },
882 | ];
883 | }
884 | return [];
885 | },
886 | },
887 | {
888 | name: "duplicate_entities",
889 | async detect(kg, metrics) {
890 | if (metrics.dataQuality.duplicateCount > 0) {
891 | return [
892 | {
893 | id: "duplicate_entities",
894 | severity: "critical",
895 | category: "integrity",
896 | description: `Found ${metrics.dataQuality.duplicateCount} duplicate entities`,
897 | affectedEntities: [],
898 | remediation: "Merge duplicate entities",
899 | detectedAt: new Date().toISOString(),
900 | autoFixable: false,
901 | },
902 | ];
903 | }
904 | return [];
905 | },
906 | },
907 | ];
908 | }
909 |
910 | // ============================================================================
911 | // Performance Tracker
912 | // ============================================================================
913 |
914 | class PerformanceTracker {
915 | private queryTimes: number[] = [];
916 | private maxSamples = 100;
917 |
918 | trackQuery(timeMs: number): void {
919 | this.queryTimes.push(timeMs);
920 | if (this.queryTimes.length > this.maxSamples) {
921 | this.queryTimes.shift();
922 | }
923 | }
924 |
925 | getAverageQueryTime(): number {
926 | if (this.queryTimes.length === 0) return 0;
927 | return (
928 | this.queryTimes.reduce((sum, t) => sum + t, 0) / this.queryTimes.length
929 | );
930 | }
931 | }
932 |
```
--------------------------------------------------------------------------------
/src/tools/readme-best-practices.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { readFile, writeFile, mkdir } from "fs/promises";
2 | import { join } from "path";
3 | import { z } from "zod";
4 | import { MCPToolResponse } from "../types/api.js";
5 |
6 | // Input validation schema
7 | const ReadmeBestPracticesInputSchema = z.object({
8 | readme_path: z.string().describe("Path to the README file to analyze"),
9 | project_type: z
10 | .enum(["library", "application", "tool", "documentation", "framework"])
11 | .optional()
12 | .default("library")
13 | .describe("Type of project for tailored analysis"),
14 | generate_template: z
15 | .boolean()
16 | .optional()
17 | .default(false)
18 | .describe("Generate README templates and community files"),
19 | output_directory: z
20 | .string()
21 | .optional()
22 | .describe("Directory to write generated templates and community files"),
23 | include_community_files: z
24 | .boolean()
25 | .optional()
26 | .default(true)
27 | .describe(
28 | "Generate community health files (CONTRIBUTING.md, CODE_OF_CONDUCT.md, etc.)",
29 | ),
30 | target_audience: z
31 | .enum(["beginner", "intermediate", "advanced", "mixed"])
32 | .optional()
33 | .default("mixed")
34 | .describe("Target audience for recommendations"),
35 | });
36 |
37 | type ReadmeBestPracticesInput = z.infer<typeof ReadmeBestPracticesInputSchema>;
38 |
39 | interface ChecklistItem {
40 | category: string;
41 | item: string;
42 | present: boolean;
43 | severity: "critical" | "important" | "recommended";
44 | description: string;
45 | example?: string;
46 | }
47 |
48 | interface BestPracticesReport {
49 | overallScore: number;
50 | grade: string;
51 | checklist: ChecklistItem[];
52 | recommendations: string[];
53 | templates: Record<string, string>;
54 | communityFiles: Record<string, string>;
55 | summary: {
56 | criticalIssues: number;
57 | importantIssues: number;
58 | recommendedImprovements: number;
59 | sectionsPresent: number;
60 | totalSections: number;
61 | estimatedImprovementTime: string;
62 | };
63 | }
64 |
65 | export async function readmeBestPractices(
66 | input: Partial<ReadmeBestPracticesInput>,
67 | ): Promise<
68 | MCPToolResponse<{
69 | bestPracticesReport: BestPracticesReport;
70 | recommendations: string[];
71 | nextSteps: string[];
72 | }>
73 | > {
74 | const startTime = Date.now();
75 |
76 | try {
77 | // Validate input with defaults
78 | const validatedInput = ReadmeBestPracticesInputSchema.parse(input);
79 | const {
80 | readme_path,
81 | project_type,
82 | generate_template,
83 | output_directory,
84 | include_community_files,
85 | target_audience,
86 | } = validatedInput;
87 |
88 | // Read README content
89 | let readmeContent = "";
90 | try {
91 | readmeContent = await readFile(readme_path, "utf-8");
92 | } catch (error) {
93 | if (!generate_template) {
94 | return {
95 | success: false,
96 | error: {
97 | code: "README_NOT_FOUND",
98 | message:
99 | "README file not found. Use generate_template: true to create a new README.",
100 | details: error instanceof Error ? error.message : "Unknown error",
101 | resolution:
102 | "Set generate_template: true to create a new README from template",
103 | },
104 | metadata: {
105 | toolVersion: "1.0.0",
106 | executionTime: Date.now() - startTime,
107 | timestamp: new Date().toISOString(),
108 | },
109 | };
110 | }
111 | }
112 |
113 | // Generate checklist based on project type and content
114 | const checklist = generateChecklist(
115 | readmeContent,
116 | project_type,
117 | target_audience,
118 | );
119 |
120 | // Calculate overall score
121 | const { score, grade } = calculateOverallScore(checklist);
122 |
123 | // Generate recommendations
124 | const recommendations = generateRecommendations(
125 | checklist,
126 | project_type,
127 | target_audience,
128 | );
129 |
130 | // Generate templates if requested
131 | const templates = generate_template
132 | ? generateTemplates(project_type, generate_template)
133 | : {};
134 |
135 | // Generate community files if requested
136 | const communityFiles = include_community_files
137 | ? generateCommunityFiles(project_type)
138 | : {};
139 |
140 | // Calculate summary metrics
141 | const summary = calculateSummaryMetrics(checklist);
142 |
143 | // Write files if output directory specified
144 | if (output_directory && generate_template) {
145 | await writeGeneratedFiles(
146 | templates,
147 | communityFiles,
148 | output_directory,
149 | readme_path,
150 | );
151 | }
152 |
153 | const report: BestPracticesReport = {
154 | overallScore: score,
155 | grade,
156 | checklist,
157 | recommendations,
158 | templates,
159 | communityFiles,
160 | summary,
161 | };
162 |
163 | const nextSteps = generateNextSteps(
164 | report.checklist,
165 | true,
166 | output_directory,
167 | );
168 |
169 | return {
170 | success: true,
171 | data: {
172 | bestPracticesReport: report,
173 | recommendations,
174 | nextSteps,
175 | },
176 | metadata: {
177 | toolVersion: "1.0.0",
178 | executionTime: Date.now() - startTime,
179 | timestamp: new Date().toISOString(),
180 | analysisId: `readme-best-practices-${Date.now()}`,
181 | },
182 | };
183 | } catch (error) {
184 | return {
185 | success: false,
186 | error: {
187 | code: "ANALYSIS_FAILED",
188 | message: "Failed to analyze README best practices",
189 | details: error instanceof Error ? error.message : "Unknown error",
190 | resolution:
191 | "Check README file path and permissions, ensure valid project type",
192 | },
193 | metadata: {
194 | toolVersion: "1.0.0",
195 | executionTime: Date.now() - startTime,
196 | timestamp: new Date().toISOString(),
197 | },
198 | };
199 | }
200 | }
201 |
202 | function generateChecklist(
203 | content: string,
204 | projectType: string,
205 | _targetAudience: string,
206 | ): ChecklistItem[] {
207 | const checklist: ChecklistItem[] = [];
208 | const lines = content.split("\n");
209 | const lowerContent = content.toLowerCase();
210 |
211 | // Essential Sections
212 | checklist.push({
213 | category: "Essential Sections",
214 | item: "Project Title",
215 | present: /^#\s+.+/m.test(content),
216 | severity: "critical",
217 | description: "Clear, descriptive project title as main heading",
218 | example: "# My Awesome Project",
219 | });
220 |
221 | checklist.push({
222 | category: "Essential Sections",
223 | item: "One-line Description",
224 | present:
225 | />\s*.+/.test(content) ||
226 | lines.some(
227 | (line) =>
228 | line.trim().length > 20 &&
229 | line.trim().length < 100 &&
230 | !line.startsWith("#"),
231 | ),
232 | severity: "critical",
233 | description: "Brief one-line description of what the project does",
234 | example:
235 | "> A fast, lightweight JavaScript framework for building web applications",
236 | });
237 |
238 | checklist.push({
239 | category: "Essential Sections",
240 | item: "Installation Instructions",
241 | present:
242 | /install/i.test(lowerContent) &&
243 | /npm|yarn|pip|cargo|go get|git clone/i.test(lowerContent),
244 | severity: "critical",
245 | description: "Clear installation or setup instructions",
246 | example: "```bash\nnpm install package-name\n```",
247 | });
248 |
249 | checklist.push({
250 | category: "Essential Sections",
251 | item: "Basic Usage Example",
252 | present:
253 | /usage|example|quick start|getting started/i.test(lowerContent) &&
254 | /```/.test(content),
255 | severity: "critical",
256 | description: "Working code example showing basic usage",
257 | example:
258 | '```javascript\nconst lib = require("package-name");\nlib.doSomething();\n```',
259 | });
260 |
261 | // Important Sections
262 | checklist.push({
263 | category: "Important Sections",
264 | item: "Prerequisites/Requirements",
265 | present:
266 | /prerequisite|requirement|dependencies|node|python|java|version/i.test(
267 | lowerContent,
268 | ),
269 | severity: "important",
270 | description: "Clear system requirements and dependencies",
271 | example: "- Node.js 16+\n- Docker (optional)",
272 | });
273 |
274 | checklist.push({
275 | category: "Important Sections",
276 | item: "License Information",
277 | present:
278 | /license/i.test(lowerContent) || /mit|apache|gpl|bsd/i.test(lowerContent),
279 | severity: "important",
280 | description: "Clear license information",
281 | example: "## License\n\nMIT License - see [LICENSE](LICENSE) file",
282 | });
283 |
284 | checklist.push({
285 | category: "Important Sections",
286 | item: "Contributing Guidelines",
287 | present: /contribut/i.test(lowerContent),
288 | severity: "important",
289 | description: "Information on how to contribute to the project",
290 | example: "See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines",
291 | });
292 |
293 | // Community Health
294 | checklist.push({
295 | category: "Community Health",
296 | item: "Code of Conduct",
297 | present: /code of conduct/i.test(lowerContent),
298 | severity: "recommended",
299 | description: "Link to code of conduct for community projects",
300 | example: "Please read our [Code of Conduct](CODE_OF_CONDUCT.md)",
301 | });
302 |
303 | checklist.push({
304 | category: "Community Health",
305 | item: "Issue Templates",
306 | present: /issue template|bug report|feature request/i.test(lowerContent),
307 | severity: "recommended",
308 | description: "Reference to issue templates for better bug reports",
309 | example:
310 | "Use our [issue templates](.github/ISSUE_TEMPLATE/) when reporting bugs",
311 | });
312 |
313 | // Visual Elements
314 | checklist.push({
315 | category: "Visual Elements",
316 | item: "Badges",
317 | present:
318 | /\[!\[.*\]\(.*\)\]\(.*\)/.test(content) || /badge/i.test(lowerContent),
319 | severity: "recommended",
320 | description: "Status badges for build, version, license, etc.",
321 | example: "[](link-url)",
322 | });
323 |
324 | checklist.push({
325 | category: "Visual Elements",
326 | item: "Screenshots/Demo",
327 | present:
328 | /!\[.*\]\(.*\.(png|jpg|jpeg|gif|webp)\)/i.test(content) ||
329 | /screenshot|demo|gif/i.test(lowerContent),
330 | severity:
331 | projectType === "application" || projectType === "tool"
332 | ? "important"
333 | : "recommended",
334 | description:
335 | "Visual demonstration of the project (especially for applications)",
336 | example: "",
337 | });
338 |
339 | // Content Quality
340 | checklist.push({
341 | category: "Content Quality",
342 | item: "Appropriate Length",
343 | present: lines.length >= 20 && lines.length <= 300,
344 | severity: "important",
345 | description:
346 | "README length appropriate for project complexity (20-300 lines)",
347 | example: "Keep main README focused, link to detailed docs",
348 | });
349 |
350 | checklist.push({
351 | category: "Content Quality",
352 | item: "Clear Section Headers",
353 | present: (content.match(/^##\s+/gm) || []).length >= 3,
354 | severity: "important",
355 | description: "Well-organized content with clear section headers",
356 | example: "## Installation\n## Usage\n## Contributing",
357 | });
358 |
359 | checklist.push({
360 | category: "Content Quality",
361 | item: "Working Links",
362 | present: !/\[.*\]\(\)/.test(content) && !/\[.*\]\(#\)/.test(content),
363 | severity: "important",
364 | description:
365 | "All links should be functional (no empty or placeholder links)",
366 | example: "[Documentation](https://example.com/docs)",
367 | });
368 |
369 | // Project-specific checks
370 | if (projectType === "library" || projectType === "framework") {
371 | checklist.push({
372 | category: "Library Specific",
373 | item: "API Documentation",
374 | present: /api|methods|functions|reference/i.test(lowerContent),
375 | severity: "important",
376 | description: "API documentation or link to detailed API reference",
377 | example:
378 | "See [API Documentation](docs/api.md) for detailed method reference",
379 | });
380 | }
381 |
382 | if (projectType === "application" || projectType === "tool") {
383 | checklist.push({
384 | category: "Application Specific",
385 | item: "Configuration Options",
386 | present: /config|settings|options|environment/i.test(lowerContent),
387 | severity: "important",
388 | description: "Configuration and customization options",
389 | example: "See [Configuration Guide](docs/configuration.md)",
390 | });
391 | }
392 |
393 | return checklist;
394 | }
395 |
396 | function calculateOverallScore(checklist: ChecklistItem[]): {
397 | score: number;
398 | grade: string;
399 | } {
400 | const weights = { critical: 3, important: 2, recommended: 1 };
401 | let totalScore = 0;
402 | let maxScore = 0;
403 |
404 | checklist.forEach((item) => {
405 | const weight = weights[item.severity];
406 | maxScore += weight;
407 | if (item.present) {
408 | totalScore += weight;
409 | }
410 | });
411 |
412 | const percentage =
413 | maxScore > 0 ? Math.round((totalScore / maxScore) * 100) : 0;
414 |
415 | let grade: string;
416 | if (percentage >= 90) grade = "A";
417 | else if (percentage >= 80) grade = "B";
418 | else if (percentage >= 70) grade = "C";
419 | else if (percentage >= 60) grade = "D";
420 | else grade = "F";
421 |
422 | return { score: percentage, grade };
423 | }
424 |
425 | function generateRecommendations(
426 | checklist: ChecklistItem[],
427 | projectType: string,
428 | targetAudience: string,
429 | ): string[] {
430 | const recommendations: string[] = [];
431 | const missing = checklist.filter((item) => !item.present);
432 |
433 | // Critical issues first
434 | const critical = missing.filter((item) => item.severity === "critical");
435 | if (critical.length > 0) {
436 | recommendations.push(
437 | `🚨 Critical: Fix ${critical.length} essential sections: ${critical
438 | .map((item) => item.item)
439 | .join(", ")}`,
440 | );
441 | }
442 |
443 | // Important issues
444 | const important = missing.filter((item) => item.severity === "important");
445 | if (important.length > 0) {
446 | recommendations.push(
447 | `⚠️ Important: Add ${important.length} key sections: ${important
448 | .map((item) => item.item)
449 | .join(", ")}`,
450 | );
451 | }
452 |
453 | // Project-specific recommendations
454 | if (projectType === "library") {
455 | recommendations.push(
456 | "📚 Library Focus: Emphasize installation, basic usage, and API documentation",
457 | );
458 | } else if (projectType === "application") {
459 | recommendations.push(
460 | "🖥️ Application Focus: Include screenshots, configuration options, and deployment guides",
461 | );
462 | }
463 |
464 | // Target audience specific recommendations
465 | if (targetAudience === "beginner") {
466 | recommendations.push(
467 | "👶 Beginner-Friendly: Use simple language, provide detailed examples, include troubleshooting",
468 | );
469 | } else if (targetAudience === "advanced") {
470 | recommendations.push(
471 | "🎯 Advanced Users: Focus on technical details, performance notes, and extensibility",
472 | );
473 | }
474 |
475 | // General improvements
476 | const recommended = missing.filter((item) => item.severity === "recommended");
477 | if (recommended.length > 0) {
478 | recommendations.push(
479 | `✨ Enhancement: Consider adding ${recommended
480 | .map((item) => item.item)
481 | .join(", ")}`,
482 | );
483 | }
484 |
485 | return recommendations;
486 | }
487 |
488 | function generateTemplates(
489 | projectType: string,
490 | _generateTemplate: boolean,
491 | ): Record<string, string> {
492 | const templates: Record<string, string> = {};
493 |
494 | if (projectType === "library") {
495 | templates["README-library.md"] = `# Project Name
496 |
497 | > One-line description of what this library does
498 |
499 | [![Build Status][build-badge]][build-link]
500 | [![npm version][npm-badge]][npm-link]
501 | [![License][license-badge]][license-link]
502 |
503 | ## TL;DR
504 |
505 | What it does in 2-3 sentences. Who should use it.
506 |
507 | ## Quick Start
508 |
509 | ### Install
510 | \`\`\`bash
511 | npm install package-name
512 | \`\`\`
513 |
514 | ### Use
515 | \`\`\`javascript
516 | const lib = require('package-name');
517 |
518 | // Basic usage example
519 | const result = lib.doSomething();
520 | console.log(result);
521 | \`\`\`
522 |
523 | ## When to Use This
524 |
525 | - ✅ When you need X functionality
526 | - ✅ When you want Y capability
527 | - ❌ When you need Z (use [alternative] instead)
528 |
529 | ## API Reference
530 |
531 | ### \`doSomething(options)\`
532 |
533 | Description of the main method.
534 |
535 | **Parameters:**
536 | - \`options\` (Object): Configuration options
537 | - \`param1\` (string): Description of parameter
538 | - \`param2\` (boolean, optional): Description of optional parameter
539 |
540 | **Returns:** Description of return value
541 |
542 | **Example:**
543 | \`\`\`javascript
544 | const result = lib.doSomething({
545 | param1: 'value',
546 | param2: true
547 | });
548 | \`\`\`
549 |
550 | ## Full Documentation
551 |
552 | [Link to full documentation](docs/)
553 |
554 | ## Contributing
555 |
556 | We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
557 |
558 | ## License
559 |
560 | MIT License - see [LICENSE](LICENSE) file for details.
561 |
562 | [build-badge]: https://github.com/username/repo/workflows/CI/badge.svg
563 | [build-link]: https://github.com/username/repo/actions
564 | [npm-badge]: https://img.shields.io/npm/v/package-name.svg
565 | [npm-link]: https://www.npmjs.com/package/package-name
566 | [license-badge]: https://img.shields.io/badge/license-MIT-blue.svg
567 | [license-link]: LICENSE
568 | `;
569 | }
570 |
571 | if (projectType === "application" || projectType === "tool") {
572 | templates["README-application.md"] = `# Project Name
573 |
574 | > One-line description of what this application does
575 |
576 | 
577 |
578 | ## What This Does
579 |
580 | Brief explanation of the application's purpose and key features:
581 |
582 | - 🚀 Feature 1: Description
583 | - 📊 Feature 2: Description
584 | - 🔧 Feature 3: Description
585 |
586 | ## Quick Start
587 |
588 | ### Prerequisites
589 | - Node.js 16+
590 | - Docker (optional)
591 | - Other requirements
592 |
593 | ### Install & Run
594 | \`\`\`bash
595 | git clone https://github.com/username/repo.git
596 | cd project-name
597 | npm install
598 | npm start
599 | \`\`\`
600 |
601 | Visit \`http://localhost:3000\` to see the application.
602 |
603 | ## Configuration
604 |
605 | ### Environment Variables
606 | \`\`\`bash
607 | # Copy example config
608 | cp .env.example .env
609 |
610 | # Edit configuration
611 | nano .env
612 | \`\`\`
613 |
614 | ### Key Settings
615 | - \`PORT\`: Server port (default: 3000)
616 | - \`DATABASE_URL\`: Database connection string
617 | - \`API_KEY\`: External service API key
618 |
619 | ## Usage Examples
620 |
621 | ### Basic Usage
622 | \`\`\`bash
623 | npm run command -- --option value
624 | \`\`\`
625 |
626 | ### Advanced Usage
627 | \`\`\`bash
628 | npm run command -- --config custom.json --verbose
629 | \`\`\`
630 |
631 | ## Deployment
632 |
633 | See [Deployment Guide](docs/deployment.md) for production setup.
634 |
635 | ## Troubleshooting
636 |
637 | ### Common Issues
638 |
639 | **Issue 1: Error message**
640 | - Solution: Steps to resolve
641 |
642 | **Issue 2: Another error**
643 | - Solution: Steps to resolve
644 |
645 | See [FAQ](docs/FAQ.md) for more help.
646 |
647 | ## Contributing
648 |
649 | We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
650 |
651 | ## License
652 |
653 | MIT License - see [LICENSE](LICENSE) file for details.
654 | `;
655 | }
656 |
657 | return templates;
658 | }
659 |
660 | function generateCommunityFiles(_projectType: string): Record<string, string> {
661 | const files: Record<string, string> = {};
662 |
663 | files["CONTRIBUTING.md"] = `# Contributing to Project Name
664 |
665 | Thank you for your interest in contributing! This document provides guidelines for contributing to this project.
666 |
667 | ## Getting Started
668 |
669 | 1. Fork the repository
670 | 2. Clone your fork: \`git clone https://github.com/yourusername/repo.git\`
671 | 3. Create a feature branch: \`git checkout -b feature-name\`
672 | 4. Make your changes
673 | 5. Test your changes: \`npm test\`
674 | 6. Commit your changes: \`git commit -m "Description of changes"\`
675 | 7. Push to your fork: \`git push origin feature-name\`
676 | 8. Create a Pull Request
677 |
678 | ## Development Setup
679 |
680 | \`\`\`bash
681 | npm install
682 | npm run dev
683 | \`\`\`
684 |
685 | ## Code Style
686 |
687 | - Use TypeScript for new code
688 | - Follow existing code formatting
689 | - Run \`npm run lint\` before committing
690 | - Add tests for new features
691 |
692 | ## Pull Request Guidelines
693 |
694 | - Keep PRs focused and small
695 | - Include tests for new functionality
696 | - Update documentation as needed
697 | - Ensure CI passes
698 | - Link to relevant issues
699 |
700 | ## Reporting Issues
701 |
702 | Use our [issue templates](.github/ISSUE_TEMPLATE/) when reporting bugs or requesting features.
703 |
704 | ## Code of Conduct
705 |
706 | Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md).
707 | `;
708 |
709 | files["CODE_OF_CONDUCT.md"] = `# Code of Conduct
710 |
711 | ## Our Pledge
712 |
713 | We pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
714 |
715 | ## Our Standards
716 |
717 | Examples of behavior that contributes to creating a positive environment include:
718 |
719 | - Using welcoming and inclusive language
720 | - Being respectful of differing viewpoints and experiences
721 | - Gracefully accepting constructive criticism
722 | - Focusing on what is best for the community
723 | - Showing empathy towards other community members
724 |
725 | Examples of unacceptable behavior include:
726 |
727 | - The use of sexualized language or imagery and unwelcome sexual attention or advances
728 | - Trolling, insulting/derogatory comments, and personal or political attacks
729 | - Public or private harassment
730 | - Publishing others' private information without explicit permission
731 | - Other conduct which could reasonably be considered inappropriate in a professional setting
732 |
733 | ## Enforcement
734 |
735 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
736 |
737 | ## Attribution
738 |
739 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 1.4.
740 | `;
741 |
742 | files["SECURITY.md"] = `# Security Policy
743 |
744 | ## Supported Versions
745 |
746 | | Version | Supported |
747 | | ------- | ------------------ |
748 | | 1.x.x | :white_check_mark: |
749 | | < 1.0 | :x: |
750 |
751 | ## Reporting a Vulnerability
752 |
753 | If you discover a security vulnerability, please report it privately:
754 |
755 | 1. **Do not** create a public issue
756 | 2. Email [email protected] with details
757 | 3. Include steps to reproduce if possible
758 | 4. We will respond within 48 hours
759 |
760 | ## Security Best Practices
761 |
762 | When using this project:
763 |
764 | - Keep dependencies updated
765 | - Use environment variables for secrets
766 | - Follow principle of least privilege
767 | - Regularly audit your setup
768 |
769 | Thank you for helping keep our project secure!
770 | `;
771 |
772 | return files;
773 | }
774 |
775 | async function writeGeneratedFiles(
776 | templates: Record<string, string>,
777 | communityFiles: Record<string, string>,
778 | outputDirectory: string,
779 | _originalReadmePath: string,
780 | ): Promise<void> {
781 | try {
782 | // Create output directory
783 | await mkdir(outputDirectory, { recursive: true });
784 |
785 | // Write templates
786 | for (const [filename, content] of Object.entries(templates)) {
787 | const filePath = join(outputDirectory, filename);
788 | await writeFile(filePath, content, "utf-8");
789 | }
790 |
791 | // Write community files
792 | for (const [filename, content] of Object.entries(communityFiles)) {
793 | const filePath = join(outputDirectory, filename);
794 | await writeFile(filePath, content, "utf-8");
795 | }
796 |
797 | // Create .github directory structure
798 | const githubDir = join(outputDirectory, ".github");
799 | await mkdir(githubDir, { recursive: true });
800 |
801 | const issueTemplateDir = join(githubDir, "ISSUE_TEMPLATE");
802 | await mkdir(issueTemplateDir, { recursive: true });
803 |
804 | // Bug report template
805 | const bugReportTemplate = `---
806 | name: Bug report
807 | about: Create a report to help us improve
808 | title: '[BUG] '
809 | labels: bug
810 | assignees: ''
811 | ---
812 |
813 | **Describe the bug**
814 | A clear and concise description of what the bug is.
815 |
816 | **To Reproduce**
817 | Steps to reproduce the behavior:
818 | 1. Go to '...'
819 | 2. Click on '....'
820 | 3. Scroll down to '....'
821 | 4. See error
822 |
823 | **Expected behavior**
824 | A clear and concise description of what you expected to happen.
825 |
826 | **Screenshots**
827 | If applicable, add screenshots to help explain your problem.
828 |
829 | **Environment:**
830 | - OS: [e.g. iOS]
831 | - Browser [e.g. chrome, safari]
832 | - Version [e.g. 22]
833 |
834 | **Additional context**
835 | Add any other context about the problem here.
836 | `;
837 |
838 | await writeFile(
839 | join(issueTemplateDir, "bug_report.yml"),
840 | bugReportTemplate,
841 | "utf-8",
842 | );
843 |
844 | // Feature request template
845 | const featureRequestTemplate = `---
846 | name: Feature request
847 | about: Suggest an idea for this project
848 | title: '[FEATURE] '
849 | labels: enhancement
850 | assignees: ''
851 | ---
852 |
853 | **Is your feature request related to a problem? Please describe.**
854 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
855 |
856 | **Describe the solution you'd like**
857 | A clear and concise description of what you want to happen.
858 |
859 | **Describe alternatives you've considered**
860 | A clear and concise description of any alternative solutions or features you've considered.
861 |
862 | **Additional context**
863 | Add any other context or screenshots about the feature request here.
864 | `;
865 |
866 | await writeFile(
867 | join(issueTemplateDir, "feature_request.yml"),
868 | featureRequestTemplate,
869 | "utf-8",
870 | );
871 |
872 | // Pull request template
873 | const prTemplate = `## Description
874 | Brief description of changes made.
875 |
876 | ## Type of Change
877 | - [ ] Bug fix (non-breaking change which fixes an issue)
878 | - [ ] New feature (non-breaking change which adds functionality)
879 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
880 | - [ ] Documentation update
881 |
882 | ## Testing
883 | - [ ] Tests pass locally
884 | - [ ] New tests added for new functionality
885 | - [ ] Manual testing completed
886 |
887 | ## Checklist
888 | - [ ] Code follows project style guidelines
889 | - [ ] Self-review completed
890 | - [ ] Documentation updated
891 | - [ ] No new warnings introduced
892 | `;
893 |
894 | await writeFile(
895 | join(githubDir, "PULL_REQUEST_TEMPLATE.md"),
896 | prTemplate,
897 | "utf-8",
898 | );
899 | } catch (error) {
900 | throw new Error(
901 | `Failed to write generated files: ${
902 | error instanceof Error ? error.message : "Unknown error"
903 | }`,
904 | );
905 | }
906 | }
907 |
908 | function calculateSummaryMetrics(checklist: ChecklistItem[]) {
909 | const criticalIssues = checklist.filter(
910 | (item) => !item.present && item.severity === "critical",
911 | ).length;
912 | const importantIssues = checklist.filter(
913 | (item) => !item.present && item.severity === "important",
914 | ).length;
915 | const recommendedImprovements = checklist.filter(
916 | (item) => !item.present && item.severity === "recommended",
917 | ).length;
918 | const sectionsPresent = checklist.filter((item) => item.present).length;
919 | const totalSections = checklist.length;
920 |
921 | // Estimate improvement time based on missing items
922 | const totalMissing =
923 | criticalIssues + importantIssues + recommendedImprovements;
924 | let estimatedTime = "";
925 | if (totalMissing === 0) {
926 | estimatedTime = "No improvements needed";
927 | } else if (totalMissing <= 3) {
928 | estimatedTime = "30 minutes - 1 hour";
929 | } else if (totalMissing <= 6) {
930 | estimatedTime = "1-2 hours";
931 | } else if (totalMissing <= 10) {
932 | estimatedTime = "2-4 hours";
933 | } else {
934 | estimatedTime = "4+ hours (consider phased approach)";
935 | }
936 |
937 | return {
938 | criticalIssues,
939 | importantIssues,
940 | recommendedImprovements,
941 | sectionsPresent,
942 | totalSections,
943 | estimatedImprovementTime: estimatedTime,
944 | };
945 | }
946 |
947 | function generateNextSteps(
948 | checklist: ChecklistItem[],
949 | generateTemplate: boolean,
950 | outputDirectory?: string,
951 | ): string[] {
952 | const nextSteps: string[] = [];
953 | const missing = checklist.filter((item) => !item.present);
954 |
955 | if (missing.length === 0) {
956 | nextSteps.push(
957 | "✅ README follows all best practices - no immediate action needed",
958 | );
959 | nextSteps.push(
960 | "📊 Consider periodic reviews to maintain quality as project evolves",
961 | );
962 | return nextSteps;
963 | }
964 |
965 | // Critical issues first
966 | const critical = missing.filter((item) => item.severity === "critical");
967 | if (critical.length > 0) {
968 | nextSteps.push(
969 | `🚨 Priority 1: Address ${critical.length} critical issues immediately`,
970 | );
971 | critical.forEach((item) => {
972 | nextSteps.push(` • Add ${item.item}: ${item.description}`);
973 | });
974 | }
975 |
976 | // Important issues
977 | const important = missing.filter((item) => item.severity === "important");
978 | if (important.length > 0) {
979 | nextSteps.push(
980 | `⚠️ Priority 2: Address ${important.length} important sections within 1 week`,
981 | );
982 | }
983 |
984 | // Template usage
985 | if (generateTemplate && outputDirectory) {
986 | nextSteps.push(`📝 Review generated templates in ${outputDirectory}/`);
987 | nextSteps.push("🔄 Customize templates to match your project specifics");
988 | nextSteps.push(
989 | "📋 Use community files (.github templates, CONTRIBUTING.md) to improve project health",
990 | );
991 | }
992 |
993 | // General improvements
994 | nextSteps.push(
995 | "🔍 Run this analysis periodically to maintain README quality",
996 | );
997 | nextSteps.push(
998 | "👥 Consider getting feedback from new users on README clarity",
999 | );
1000 |
1001 | return nextSteps;
1002 | }
1003 |
```
--------------------------------------------------------------------------------
/src/tools/recommend-ssg.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { MCPToolResponse, formatMCPResponse } from "../types/api.js";
3 | import {
4 | getKnowledgeGraph,
5 | getProjectContext,
6 | getMemoryManager,
7 | } from "../memory/kg-integration.js";
8 | import { getUserPreferenceManager } from "../memory/user-preferences.js";
9 |
10 | // SSG scoring matrix based on ADR-003
11 | export interface SSGRecommendation {
12 | recommended: "jekyll" | "hugo" | "docusaurus" | "mkdocs" | "eleventy";
13 | confidence: number;
14 | reasoning: string[];
15 | alternatives: Array<{
16 | name: string;
17 | score: number;
18 | pros: string[];
19 | cons: string[];
20 | }>;
21 | historicalData?: {
22 | similarProjectCount: number;
23 | successRates: Record<string, { rate: number; sampleSize: number }>;
24 | topPerformer?: {
25 | ssg: string;
26 | successRate: number;
27 | deploymentCount: number;
28 | };
29 | };
30 | }
31 |
32 | const inputSchema = z.object({
33 | analysisId: z.string(),
34 | userId: z.string().optional().default("default"),
35 | preferences: z
36 | .object({
37 | priority: z.enum(["simplicity", "features", "performance"]).optional(),
38 | ecosystem: z
39 | .enum(["javascript", "python", "ruby", "go", "any"])
40 | .optional(),
41 | })
42 | .optional(),
43 | });
44 |
45 | /**
46 | * Phase 2.1: Retrieve historical deployment data from knowledge graph
47 | */
48 | async function getHistoricalDeploymentData(
49 | projectPath?: string,
50 | technologies?: string[],
51 | ): Promise<{
52 | similarProjectCount: number;
53 | successRates: Record<string, { rate: number; sampleSize: number }>;
54 | topPerformer?: {
55 | ssg: string;
56 | successRate: number;
57 | deploymentCount: number;
58 | };
59 | globalTopPerformer?: {
60 | ssg: string;
61 | successRate: number;
62 | deploymentCount: number;
63 | };
64 | }> {
65 | try {
66 | const kg = await getKnowledgeGraph();
67 |
68 | // Get ALL projects for finding global top performers
69 | const allProjects = await kg.findNodes({ type: "project" });
70 |
71 | // Find similar projects (either by path or by shared technologies)
72 | let similarProjects = allProjects;
73 |
74 | if (projectPath) {
75 | // Get context for current project
76 | const context = await getProjectContext(projectPath);
77 |
78 | // If project exists in KG, use its similar projects
79 | if (context.similarProjects.length > 0) {
80 | similarProjects = context.similarProjects;
81 | } else if (technologies && technologies.length > 0) {
82 | // Project doesn't exist yet, but we have technologies - find similar by tech
83 | const techSet = new Set(technologies.map((t) => t.toLowerCase()));
84 | const projectsWithTech = [] as typeof similarProjects;
85 |
86 | for (const project of allProjects) {
87 | const projectTechs = project.properties.technologies || [];
88 | const hasShared = projectTechs.some((t: string) =>
89 | techSet.has(t.toLowerCase()),
90 | );
91 | if (hasShared) {
92 | projectsWithTech.push(project);
93 | }
94 | }
95 | similarProjects = projectsWithTech;
96 | } else {
97 | // No project found and no technologies provided
98 | similarProjects = [];
99 | }
100 | } else if (technologies && technologies.length > 0) {
101 | // Filter by shared technologies
102 | const techSet = new Set(technologies.map((t) => t.toLowerCase()));
103 | const projectsWithTech = [] as typeof similarProjects;
104 |
105 | for (const project of allProjects) {
106 | const projectTechs = project.properties.technologies || [];
107 | const hasShared = projectTechs.some((t: string) =>
108 | techSet.has(t.toLowerCase()),
109 | );
110 | if (hasShared) {
111 | projectsWithTech.push(project);
112 | }
113 | }
114 | similarProjects = projectsWithTech;
115 | } else {
116 | // No criteria provided
117 | similarProjects = [];
118 | }
119 |
120 | // Aggregate deployment data by SSG for similar projects
121 | const ssgStats: Record<
122 | string,
123 | { successes: number; failures: number; total: number }
124 | > = {};
125 |
126 | // Also track global stats across ALL projects for finding top performers
127 | const globalSSGStats: Record<
128 | string,
129 | { successes: number; failures: number; total: number }
130 | > = {};
131 |
132 | // Helper function to aggregate stats for a set of projects
133 | const aggregateStats = async (projects: typeof allProjects) => {
134 | const stats: Record<
135 | string,
136 | { successes: number; failures: number; total: number }
137 | > = {};
138 |
139 | for (const project of projects) {
140 | const allEdges = await kg.findEdges({ source: project.id });
141 | const deployments = allEdges.filter(
142 | (e) =>
143 | e.type.startsWith("project_deployed_with") ||
144 | e.properties.baseType === "project_deployed_with",
145 | );
146 |
147 | for (const deployment of deployments) {
148 | const allNodes = await kg.getAllNodes();
149 | const configNode = allNodes.find((n) => n.id === deployment.target);
150 |
151 | if (configNode && configNode.type === "configuration") {
152 | const ssg = configNode.properties.ssg;
153 | if (!stats[ssg]) {
154 | stats[ssg] = { successes: 0, failures: 0, total: 0 };
155 | }
156 |
157 | stats[ssg].total++;
158 | if (deployment.properties.success) {
159 | stats[ssg].successes++;
160 | } else {
161 | stats[ssg].failures++;
162 | }
163 | }
164 | }
165 | }
166 | return stats;
167 | };
168 |
169 | // Aggregate for similar projects
170 | Object.assign(ssgStats, await aggregateStats(similarProjects));
171 |
172 | // Aggregate for ALL projects (for global top performer)
173 | Object.assign(globalSSGStats, await aggregateStats(allProjects));
174 |
175 | // Calculate success rates for similar projects
176 | const successRates: Record<string, { rate: number; sampleSize: number }> =
177 | {};
178 | let topPerformer:
179 | | { ssg: string; successRate: number; deploymentCount: number }
180 | | undefined;
181 | let maxRate = 0;
182 |
183 | for (const [ssg, stats] of Object.entries(ssgStats)) {
184 | if (stats.total > 0) {
185 | const rate = stats.successes / stats.total;
186 | successRates[ssg] = {
187 | rate,
188 | sampleSize: stats.total,
189 | };
190 |
191 | // Track top performer in similar projects (require at least 2 deployments)
192 | if (stats.total >= 2 && rate > maxRate) {
193 | maxRate = rate;
194 | topPerformer = {
195 | ssg,
196 | successRate: rate,
197 | deploymentCount: stats.total,
198 | };
199 | }
200 | }
201 | }
202 |
203 | // Calculate global top performer from ALL projects
204 | let globalTopPerformer:
205 | | { ssg: string; successRate: number; deploymentCount: number }
206 | | undefined;
207 | let globalMaxRate = 0;
208 |
209 | for (const [ssg, stats] of Object.entries(globalSSGStats)) {
210 | if (stats.total >= 2) {
211 | const rate = stats.successes / stats.total;
212 | if (rate > globalMaxRate) {
213 | globalMaxRate = rate;
214 | globalTopPerformer = {
215 | ssg,
216 | successRate: rate,
217 | deploymentCount: stats.total,
218 | };
219 | }
220 | }
221 | }
222 |
223 | return {
224 | similarProjectCount: similarProjects.length,
225 | successRates,
226 | topPerformer,
227 | globalTopPerformer,
228 | };
229 | } catch (error) {
230 | console.warn("Failed to retrieve historical deployment data:", error);
231 | return {
232 | similarProjectCount: 0,
233 | successRates: {},
234 | };
235 | }
236 | }
237 |
238 | /**
239 | * Recommends the optimal static site generator (SSG) for a project based on analysis and historical data.
240 | *
241 | * This function provides intelligent SSG recommendations by analyzing project characteristics,
242 | * considering user preferences, and leveraging historical deployment data from the knowledge graph.
243 | * It uses a multi-criteria decision analysis approach to score different SSGs and provide
244 | * confidence-weighted recommendations with detailed reasoning.
245 | *
246 | * @param args - The input arguments for SSG recommendation
247 | * @param args.analysisId - Unique identifier from a previous repository analysis
248 | * @param args.userId - User identifier for personalized recommendations (defaults to "default")
249 | * @param args.preferences - Optional user preferences for recommendation weighting
250 | * @param args.preferences.priority - Priority focus: "simplicity", "features", or "performance"
251 | * @param args.preferences.ecosystem - Preferred technology ecosystem: "javascript", "python", "ruby", "go", or "any"
252 | *
253 | * @returns Promise resolving to SSG recommendation results
254 | * @returns content - Array containing the recommendation results in MCP tool response format
255 | *
256 | * @throws {Error} When the analysis ID is invalid or not found
257 | * @throws {Error} When historical data cannot be retrieved
258 | * @throws {Error} When recommendation scoring fails
259 | *
260 | * @example
261 | * ```typescript
262 | * // Basic recommendation
263 | * const recommendation = await recommendSSG({
264 | * analysisId: "analysis_abc123_def456",
265 | * userId: "user123"
266 | * });
267 | *
268 | * // With preferences
269 | * const personalized = await recommendSSG({
270 | * analysisId: "analysis_abc123_def456",
271 | * userId: "user123",
272 | * preferences: {
273 | * priority: "performance",
274 | * ecosystem: "javascript"
275 | * }
276 | * });
277 | * ```
278 | *
279 | * @since 1.0.0
280 | * @version 1.2.0 - Added historical data integration and user preferences
281 | */
282 | export async function recommendSSG(
283 | args: unknown,
284 | context?: any,
285 | ): Promise<{ content: any[] }> {
286 | const startTime = Date.now();
287 | const { analysisId, userId, preferences } = inputSchema.parse(args);
288 |
289 | const prioritizeSimplicity = preferences?.priority === "simplicity";
290 | const ecosystemPreference = preferences?.ecosystem;
291 |
292 | // Report initial progress
293 | if (context?.meta?.progressToken) {
294 | await context.meta.reportProgress?.({
295 | progress: 0,
296 | total: 100,
297 | });
298 | }
299 |
300 | await context?.info?.("🔍 Starting SSG recommendation engine...");
301 |
302 | // Phase 2.2: Get user preference manager
303 | await context?.info?.(`👤 Loading preferences for user: ${userId}...`);
304 | const userPreferenceManager = await getUserPreferenceManager(userId);
305 |
306 | if (context?.meta?.progressToken) {
307 | await context.meta.reportProgress?.({
308 | progress: 15,
309 | total: 100,
310 | });
311 | }
312 |
313 | try {
314 | // Try to retrieve analysis from memory
315 | await context?.info?.(`📊 Retrieving analysis: ${analysisId}...`);
316 | let analysisData = null;
317 | try {
318 | const manager = await getMemoryManager();
319 | const analysis = await manager.recall(analysisId);
320 | if (analysis && analysis.data) {
321 | // Handle the wrapped content structure
322 | if (analysis.data.content && Array.isArray(analysis.data.content)) {
323 | // Extract the JSON from the first text content
324 | const firstContent = analysis.data.content[0];
325 | if (
326 | firstContent &&
327 | firstContent.type === "text" &&
328 | firstContent.text
329 | ) {
330 | try {
331 | analysisData = JSON.parse(firstContent.text);
332 | } catch (parseError) {
333 | // If parse fails, try the direct data
334 | analysisData = analysis.data;
335 | }
336 | }
337 | } else {
338 | // Direct data structure
339 | analysisData = analysis.data;
340 | }
341 | }
342 | } catch (error) {
343 | // If memory retrieval fails, continue with fallback logic
344 | console.warn(
345 | `Could not retrieve analysis ${analysisId} from memory:`,
346 | error,
347 | );
348 | }
349 |
350 | if (context?.meta?.progressToken) {
351 | await context.meta.reportProgress?.({
352 | progress: 30,
353 | total: 100,
354 | });
355 | }
356 |
357 | // Phase 2.1: Retrieve historical deployment data
358 | await context?.info?.("📈 Analyzing historical deployment data...");
359 | let historicalData:
360 | | {
361 | similarProjectCount: number;
362 | successRates: Record<string, { rate: number; sampleSize: number }>;
363 | topPerformer?: {
364 | ssg: string;
365 | successRate: number;
366 | deploymentCount: number;
367 | };
368 | globalTopPerformer?: {
369 | ssg: string;
370 | successRate: number;
371 | deploymentCount: number;
372 | };
373 | }
374 | | undefined;
375 |
376 | if (analysisData) {
377 | const projectPath = analysisData.path;
378 | const technologies = analysisData.dependencies?.languages || [];
379 | historicalData = await getHistoricalDeploymentData(
380 | projectPath,
381 | technologies,
382 | );
383 |
384 | if (historicalData && historicalData.similarProjectCount > 0) {
385 | await context?.info?.(
386 | `✨ Found ${historicalData.similarProjectCount} similar project(s) with deployment history`,
387 | );
388 | }
389 | }
390 |
391 | if (context?.meta?.progressToken) {
392 | await context.meta.reportProgress?.({
393 | progress: 50,
394 | total: 100,
395 | });
396 | }
397 |
398 | await context?.info?.("🤔 Calculating SSG recommendations...");
399 |
400 | // Determine recommendation based on analysis data if available
401 | let finalRecommendation:
402 | | "jekyll"
403 | | "hugo"
404 | | "docusaurus"
405 | | "mkdocs"
406 | | "eleventy";
407 | let reasoning: string[] = [];
408 | let confidence = 0.85;
409 |
410 | if (analysisData) {
411 | // Use actual analysis data to make informed recommendation
412 | const ecosystem = analysisData.dependencies?.ecosystem || "unknown";
413 | const hasReact = analysisData.dependencies?.packages?.some(
414 | (p: string) => p.includes("react") || p.includes("next"),
415 | );
416 | const complexity =
417 | analysisData.documentation?.estimatedComplexity || "moderate";
418 | const teamSize = analysisData.recommendations?.teamSize || "small";
419 |
420 | // Logic based on real analysis
421 | if (ecosystem === "python") {
422 | finalRecommendation = "mkdocs";
423 | reasoning = [
424 | "Python ecosystem detected - MkDocs integrates naturally",
425 | "Simple configuration with YAML",
426 | "Material theme provides excellent UI out of the box",
427 | "Strong Python community support",
428 | ];
429 | } else if (ecosystem === "ruby") {
430 | finalRecommendation = "jekyll";
431 | reasoning = [
432 | "Ruby ecosystem detected - Jekyll is the native choice",
433 | "GitHub Pages native support",
434 | "Simple static site generation",
435 | "Extensive theme ecosystem",
436 | ];
437 | } else if (hasReact || ecosystem === "javascript") {
438 | if (complexity === "complex" || teamSize === "large") {
439 | finalRecommendation = "docusaurus";
440 | reasoning = [
441 | "JavaScript/TypeScript ecosystem with React detected",
442 | "Complex project structure benefits from Docusaurus features",
443 | "Built-in versioning and internationalization",
444 | "MDX support for interactive documentation",
445 | ];
446 | } else if (prioritizeSimplicity) {
447 | finalRecommendation = "eleventy";
448 | reasoning = [
449 | "JavaScript ecosystem with simplicity priority",
450 | "Minimal configuration required",
451 | "Fast build times",
452 | "Flexible templating options",
453 | ];
454 | } else {
455 | finalRecommendation = "docusaurus";
456 | reasoning = [
457 | "JavaScript/TypeScript ecosystem detected",
458 | "Modern React-based framework",
459 | "Active community and regular updates",
460 | "Great developer experience",
461 | ];
462 | }
463 | } else if (ecosystem === "go") {
464 | finalRecommendation = "hugo";
465 | reasoning = [
466 | "Go ecosystem detected - Hugo is written in Go",
467 | "Extremely fast build times",
468 | "No runtime dependencies",
469 | "Excellent for large documentation sites",
470 | ];
471 | } else {
472 | // Default logic when ecosystem is unknown
473 | if (prioritizeSimplicity) {
474 | finalRecommendation = "jekyll";
475 | reasoning = [
476 | "Simple setup and configuration",
477 | "GitHub Pages native support",
478 | "Extensive documentation and community",
479 | "Mature and stable platform",
480 | ];
481 | } else {
482 | finalRecommendation = "docusaurus";
483 | reasoning = [
484 | "Modern documentation framework",
485 | "Rich feature set out of the box",
486 | "Great for technical documentation",
487 | "Active development and support",
488 | ];
489 | }
490 | }
491 |
492 | // Apply preference overrides
493 | if (ecosystemPreference && ecosystemPreference !== "any") {
494 | if (ecosystemPreference === "python") {
495 | finalRecommendation = "mkdocs";
496 | reasoning.unshift("Python ecosystem explicitly requested");
497 | } else if (ecosystemPreference === "ruby") {
498 | finalRecommendation = "jekyll";
499 | reasoning.unshift("Ruby ecosystem explicitly requested");
500 | } else if (ecosystemPreference === "go") {
501 | finalRecommendation = "hugo";
502 | reasoning.unshift("Go ecosystem explicitly requested");
503 | } else if (ecosystemPreference === "javascript") {
504 | if (
505 | finalRecommendation !== "docusaurus" &&
506 | finalRecommendation !== "eleventy"
507 | ) {
508 | finalRecommendation = prioritizeSimplicity
509 | ? "eleventy"
510 | : "docusaurus";
511 | reasoning.unshift("JavaScript ecosystem explicitly requested");
512 | }
513 | }
514 | }
515 |
516 | // Adjust confidence based on data quality
517 | if (analysisData.structure?.totalFiles > 100) {
518 | confidence = Math.min(0.95, confidence + 0.05);
519 | }
520 | if (
521 | analysisData.documentation?.hasReadme &&
522 | analysisData.documentation?.hasDocs
523 | ) {
524 | confidence = Math.min(0.95, confidence + 0.05);
525 | }
526 |
527 | // Phase 2.1: Adjust recommendation and confidence based on historical data
528 | if (historicalData && historicalData.similarProjectCount >= 0) {
529 | const recommendedSuccessRate =
530 | historicalData.successRates[finalRecommendation];
531 |
532 | if (recommendedSuccessRate) {
533 | // Boost confidence if historically successful
534 | if (recommendedSuccessRate.rate >= 1.0) {
535 | // Perfect success rate - maximum boost
536 | confidence = Math.min(0.98, confidence + 0.2);
537 | reasoning.unshift(
538 | `✅ 100% success rate in ${recommendedSuccessRate.sampleSize} similar project(s)`,
539 | );
540 | } else if (
541 | recommendedSuccessRate.rate > 0.8 &&
542 | recommendedSuccessRate.sampleSize >= 2
543 | ) {
544 | // High success rate - good boost
545 | confidence = Math.min(0.98, confidence + 0.15);
546 | reasoning.unshift(
547 | `✅ ${(recommendedSuccessRate.rate * 100).toFixed(
548 | 0,
549 | )}% success rate in ${
550 | recommendedSuccessRate.sampleSize
551 | } similar project(s)`,
552 | );
553 | } else if (
554 | recommendedSuccessRate.rate < 0.5 &&
555 | recommendedSuccessRate.sampleSize >= 2
556 | ) {
557 | // Reduce confidence if historically problematic
558 | confidence = Math.max(0.5, confidence - 0.15);
559 | reasoning.unshift(
560 | `⚠️ Only ${(recommendedSuccessRate.rate * 100).toFixed(
561 | 0,
562 | )}% success rate in ${
563 | recommendedSuccessRate.sampleSize
564 | } similar project(s)`,
565 | );
566 | }
567 | } else {
568 | // No deployment history for recommended SSG
569 | // Check if similar projects had poor outcomes with OTHER SSGs
570 | // This indicates general deployment challenges
571 | const allSuccessRates = Object.values(historicalData.successRates);
572 | if (allSuccessRates.length > 0) {
573 | const avgSuccessRate =
574 | allSuccessRates.reduce((sum, data) => sum + data.rate, 0) /
575 | allSuccessRates.length;
576 | const totalSamples = allSuccessRates.reduce(
577 | (sum, data) => sum + data.sampleSize,
578 | 0,
579 | );
580 |
581 | // If similar projects had poor deployment success overall, reduce confidence
582 | if (avgSuccessRate < 0.5 && totalSamples >= 2) {
583 | confidence = Math.max(0.6, confidence - 0.2);
584 | // Find the SSG with worst performance to mention
585 | const worstSSG = Object.entries(
586 | historicalData.successRates,
587 | ).reduce(
588 | (worst, [ssg, data]) =>
589 | data.rate < worst.rate ? { ssg, rate: data.rate } : worst,
590 | { ssg: "", rate: 1.0 },
591 | );
592 | reasoning.unshift(
593 | `⚠️ Similar projects had deployment challenges (${
594 | worstSSG.ssg
595 | }: ${(worstSSG.rate * 100).toFixed(0)}% success rate)`,
596 | );
597 | }
598 | }
599 | }
600 |
601 | // Consider switching to top performer if significantly better
602 | // Prefer similar project top performer, fall back to global top performer
603 | const performerToConsider =
604 | historicalData.topPerformer || historicalData.globalTopPerformer;
605 |
606 | if (
607 | performerToConsider &&
608 | performerToConsider.ssg !== finalRecommendation
609 | ) {
610 | const topPerformer = performerToConsider;
611 | const currentRate = recommendedSuccessRate?.rate || 0.5;
612 | const isFromSimilarProjects = !!historicalData.topPerformer;
613 |
614 | // Only switch if from similar projects (same ecosystem/technologies)
615 | // For cross-ecosystem recommendations, just mention as alternative
616 | const shouldSwitch =
617 | isFromSimilarProjects &&
618 | topPerformer.successRate > currentRate + 0.2 &&
619 | topPerformer.deploymentCount >= 2;
620 |
621 | const shouldMention =
622 | !shouldSwitch &&
623 | topPerformer.successRate >= 0.8 &&
624 | topPerformer.deploymentCount >= 2;
625 |
626 | if (shouldSwitch) {
627 | reasoning.unshift(
628 | `📊 Switching to ${topPerformer.ssg} based on ${(
629 | topPerformer.successRate * 100
630 | ).toFixed(0)}% success rate across ${
631 | topPerformer.deploymentCount
632 | } deployments`,
633 | );
634 | finalRecommendation = topPerformer.ssg as
635 | | "jekyll"
636 | | "hugo"
637 | | "docusaurus"
638 | | "mkdocs"
639 | | "eleventy";
640 | confidence = Math.min(0.95, topPerformer.successRate + 0.1);
641 | } else if (shouldMention) {
642 | // Mention as alternative if it has good success rate
643 | const projectScope = isFromSimilarProjects
644 | ? "similar projects"
645 | : "all projects";
646 | reasoning.push(
647 | `💡 Alternative: ${topPerformer.ssg} has ${(
648 | topPerformer.successRate * 100
649 | ).toFixed(0)}% success rate in ${projectScope}`,
650 | );
651 | }
652 | }
653 |
654 | // Add general historical context
655 | if (historicalData.similarProjectCount >= 2) {
656 | const totalDeployments = Object.values(
657 | historicalData.successRates,
658 | ).reduce((sum, data) => sum + data.sampleSize, 0);
659 | reasoning.push(
660 | `📚 Based on ${totalDeployments} deployment(s) across ${historicalData.similarProjectCount} similar project(s)`,
661 | );
662 | }
663 | }
664 | } else {
665 | // Fallback logic when no analysis data is available
666 | const baseRecommendation = prioritizeSimplicity ? "jekyll" : "docusaurus";
667 | finalRecommendation =
668 | ecosystemPreference === "python" ? "mkdocs" : baseRecommendation;
669 | reasoning = [
670 | "Recommendation based on preferences without full analysis",
671 | "Consider running analyze_repository for more accurate recommendation",
672 | ];
673 | confidence = 0.65; // Lower confidence without analysis data
674 | }
675 |
676 | // Phase 2.2: Apply user preferences to recommendation
677 | // For preference checking, include all SSGs except the current recommendation
678 | // This ensures user preferences can override even if their preferred SSG isn't in top alternatives
679 | const allSSGs: Array<
680 | "jekyll" | "hugo" | "docusaurus" | "mkdocs" | "eleventy"
681 | > = ["jekyll", "hugo", "docusaurus", "mkdocs", "eleventy"];
682 | const alternativeNames = allSSGs.filter(
683 | (ssg) => ssg !== finalRecommendation,
684 | );
685 |
686 | const preferenceAdjustment =
687 | userPreferenceManager.applyPreferencesToRecommendation(
688 | finalRecommendation,
689 | alternativeNames,
690 | );
691 |
692 | if (preferenceAdjustment.adjustmentReason) {
693 | // User preferences led to a different recommendation
694 | finalRecommendation = preferenceAdjustment.recommended as
695 | | "jekyll"
696 | | "hugo"
697 | | "docusaurus"
698 | | "mkdocs"
699 | | "eleventy";
700 | reasoning.unshift(`🎯 ${preferenceAdjustment.adjustmentReason}`);
701 | confidence = Math.min(0.95, confidence + 0.05);
702 | }
703 |
704 | const recommendation: SSGRecommendation = {
705 | recommended: finalRecommendation,
706 | confidence,
707 | reasoning,
708 | alternatives: getAlternatives(finalRecommendation, prioritizeSimplicity),
709 | historicalData,
710 | };
711 |
712 | if (context?.meta?.progressToken) {
713 | await context.meta.reportProgress?.({
714 | progress: 100,
715 | total: 100,
716 | });
717 | }
718 |
719 | const executionTime = Date.now() - startTime;
720 | await context?.info?.(
721 | `✅ Recommendation complete! Suggesting ${recommendation.recommended.toUpperCase()} with ${(
722 | recommendation.confidence * 100
723 | ).toFixed(0)}% confidence (${Math.round(executionTime / 1000)}s)`,
724 | );
725 |
726 | const response: MCPToolResponse<SSGRecommendation> = {
727 | success: true,
728 | data: recommendation,
729 | metadata: {
730 | toolVersion: "1.0.0",
731 | executionTime,
732 | timestamp: new Date().toISOString(),
733 | analysisId,
734 | },
735 | recommendations: [
736 | {
737 | type: "info",
738 | title: "SSG Recommendation",
739 | description: `${recommendation.recommended} recommended with ${(
740 | recommendation.confidence * 100
741 | ).toFixed(0)}% confidence`,
742 | },
743 | ],
744 | nextSteps: [
745 | {
746 | action: "Generate Configuration",
747 | toolRequired: "generate_config",
748 | description: `Create ${recommendation.recommended} configuration files`,
749 | priority: "high",
750 | },
751 | ],
752 | };
753 |
754 | return formatMCPResponse(response);
755 | } catch (error) {
756 | const errorResponse: MCPToolResponse = {
757 | success: false,
758 | error: {
759 | code: "RECOMMENDATION_FAILED",
760 | message: `Failed to generate SSG recommendation: ${error}`,
761 | resolution:
762 | "Ensure analysis ID is valid and preferences are correctly formatted",
763 | },
764 | metadata: {
765 | toolVersion: "1.0.0",
766 | executionTime: Date.now() - startTime,
767 | timestamp: new Date().toISOString(),
768 | analysisId,
769 | },
770 | };
771 | return formatMCPResponse(errorResponse);
772 | }
773 | }
774 |
775 | function getAlternatives(
776 | recommended: string,
777 | prioritizeSimplicity: boolean,
778 | ): SSGRecommendation["alternatives"] {
779 | const allSSGs = [
780 | {
781 | name: "Jekyll",
782 | score: prioritizeSimplicity ? 0.85 : 0.7,
783 | pros: [
784 | "Simple setup",
785 | "GitHub Pages native",
786 | "Extensive themes",
787 | "Ruby ecosystem",
788 | ],
789 | cons: [
790 | "Ruby dependency",
791 | "Slower builds for large sites",
792 | "Limited dynamic features",
793 | ],
794 | },
795 | {
796 | name: "Hugo",
797 | score: prioritizeSimplicity ? 0.65 : 0.75,
798 | pros: [
799 | "Extremely fast builds",
800 | "No dependencies",
801 | "Go templating",
802 | "Great for large sites",
803 | ],
804 | cons: [
805 | "Steeper learning curve",
806 | "Go templating may be unfamiliar",
807 | "Less flexible themes",
808 | ],
809 | },
810 | {
811 | name: "Docusaurus",
812 | score: prioritizeSimplicity ? 0.7 : 0.9,
813 | pros: [
814 | "React-based",
815 | "Rich features",
816 | "MDX support",
817 | "Built-in versioning",
818 | ],
819 | cons: [
820 | "More complex setup",
821 | "Node.js dependency",
822 | "Heavier than static generators",
823 | ],
824 | },
825 | {
826 | name: "MkDocs",
827 | score: prioritizeSimplicity ? 0.8 : 0.75,
828 | pros: [
829 | "Simple setup",
830 | "Python-based",
831 | "Great themes",
832 | "Easy configuration",
833 | ],
834 | cons: [
835 | "Python dependency",
836 | "Less flexible than React-based",
837 | "Limited customization",
838 | ],
839 | },
840 | {
841 | name: "Eleventy",
842 | score: prioritizeSimplicity ? 0.75 : 0.7,
843 | pros: [
844 | "Minimal config",
845 | "Fast builds",
846 | "Flexible templates",
847 | "JavaScript ecosystem",
848 | ],
849 | cons: [
850 | "Less opinionated",
851 | "Fewer built-in features",
852 | "Requires more setup for complex sites",
853 | ],
854 | },
855 | ];
856 |
857 | // Filter out the recommended SSG and sort by score
858 | return allSSGs
859 | .filter((ssg) => ssg.name.toLowerCase() !== recommended.toLowerCase())
860 | .sort((a, b) => b.score - a.score)
861 | .slice(0, 2); // Return top 2 alternatives
862 | }
863 |
```
--------------------------------------------------------------------------------
/tests/functional/tools.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Functional tests for all MCP tools with real repository scenarios
2 | import { promises as fs } from "fs";
3 | import path from "path";
4 | import os from "os";
5 | import { analyzeRepository } from "../../src/tools/analyze-repository";
6 | import { recommendSSG } from "../../src/tools/recommend-ssg";
7 | import { generateConfig } from "../../src/tools/generate-config";
8 | import { setupStructure } from "../../src/tools/setup-structure";
9 | import { deployPages } from "../../src/tools/deploy-pages";
10 | import { verifyDeployment } from "../../src/tools/verify-deployment";
11 |
12 | describe("Functional Testing - MCP Tools", () => {
13 | let tempDir: string;
14 | let testRepos: {
15 | javascript: string;
16 | python: string;
17 | ruby: string;
18 | go: string;
19 | mixed: string;
20 | large: string;
21 | empty: string;
22 | };
23 |
24 | beforeAll(async () => {
25 | tempDir = path.join(os.tmpdir(), "documcp-functional-tests");
26 | await fs.mkdir(tempDir, { recursive: true });
27 |
28 | testRepos = {
29 | javascript: await createJavaScriptRepo(),
30 | python: await createPythonRepo(),
31 | ruby: await createRubyRepo(),
32 | go: await createGoRepo(),
33 | mixed: await createMixedLanguageRepo(),
34 | large: await createLargeRepo(),
35 | empty: await createEmptyRepo(),
36 | };
37 | });
38 |
39 | afterAll(async () => {
40 | try {
41 | await fs.rm(tempDir, { recursive: true, force: true });
42 | } catch (error) {
43 | console.warn("Failed to cleanup test directory:", error);
44 | }
45 | });
46 |
47 | describe("analyze_repository Tool", () => {
48 | it("should analyze JavaScript/TypeScript repository correctly", async () => {
49 | const result = await analyzeRepository({
50 | path: testRepos.javascript,
51 | depth: "standard",
52 | });
53 |
54 | expect(result.content).toBeDefined();
55 | expect(result.content.length).toBeGreaterThan(0);
56 |
57 | // Parse the JSON response to validate structure
58 | const analysisText = result.content.find((c) => c.text.includes('"id"'));
59 | expect(analysisText).toBeDefined();
60 |
61 | const analysis = JSON.parse(analysisText!.text);
62 | expect(analysis.dependencies.ecosystem).toBe("javascript");
63 | expect(analysis.structure.languages[".js"]).toBeGreaterThan(0);
64 | expect(analysis.documentation.hasReadme).toBe(true);
65 | expect(analysis.recommendations.primaryLanguage).toBe("javascript");
66 | });
67 |
68 | it("should analyze Python repository correctly", async () => {
69 | const result = await analyzeRepository({
70 | path: testRepos.python,
71 | depth: "standard",
72 | });
73 |
74 | const analysisText = result.content.find((c) =>
75 | c.text.includes('"ecosystem"'),
76 | );
77 | const analysis = JSON.parse(analysisText!.text);
78 |
79 | expect(analysis.dependencies.ecosystem).toBe("python");
80 | expect(analysis.structure.languages[".py"]).toBeGreaterThan(0);
81 | expect(analysis.dependencies.packages.length).toBeGreaterThan(0);
82 | });
83 |
84 | it("should analyze Ruby repository correctly", async () => {
85 | const result = await analyzeRepository({
86 | path: testRepos.ruby,
87 | depth: "standard",
88 | });
89 |
90 | const analysisText = result.content.find((c) =>
91 | c.text.includes('"ecosystem"'),
92 | );
93 | const analysis = JSON.parse(analysisText!.text);
94 |
95 | expect(analysis.dependencies.ecosystem).toBe("ruby");
96 | expect(analysis.structure.languages[".rb"]).toBeGreaterThan(0);
97 | });
98 |
99 | it("should analyze Go repository correctly", async () => {
100 | const result = await analyzeRepository({
101 | path: testRepos.go,
102 | depth: "standard",
103 | });
104 |
105 | const analysisText = result.content.find((c) =>
106 | c.text.includes('"ecosystem"'),
107 | );
108 | const analysis = JSON.parse(analysisText!.text);
109 |
110 | expect(analysis.dependencies.ecosystem).toBe("go");
111 | expect(analysis.structure.languages[".go"]).toBeGreaterThan(0);
112 | });
113 |
114 | it("should handle different analysis depths", async () => {
115 | const quickResult = await analyzeRepository({
116 | path: testRepos.javascript,
117 | depth: "quick",
118 | });
119 |
120 | const deepResult = await analyzeRepository({
121 | path: testRepos.javascript,
122 | depth: "deep",
123 | });
124 |
125 | expect(quickResult.content).toBeDefined();
126 | expect(deepResult.content).toBeDefined();
127 |
128 | // Both should return valid results but potentially different detail levels
129 | const quickAnalysis = JSON.parse(
130 | quickResult.content.find((c) => c.text.includes('"id"'))!.text,
131 | );
132 | const deepAnalysis = JSON.parse(
133 | deepResult.content.find((c) => c.text.includes('"id"'))!.text,
134 | );
135 |
136 | expect(quickAnalysis.id).toBeDefined();
137 | expect(deepAnalysis.id).toBeDefined();
138 | });
139 |
140 | it("should handle empty repository gracefully", async () => {
141 | const result = await analyzeRepository({
142 | path: testRepos.empty,
143 | depth: "standard",
144 | });
145 |
146 | const analysisText = result.content.find((c) =>
147 | c.text.includes('"totalFiles"'),
148 | );
149 | const analysis = JSON.parse(analysisText!.text);
150 |
151 | expect(analysis.structure.totalFiles).toBe(1); // Only README.md
152 | expect(analysis.dependencies.ecosystem).toBe("unknown");
153 | });
154 |
155 | it("should handle non-existent repository path", async () => {
156 | const nonExistentPath = path.join(tempDir, "does-not-exist");
157 |
158 | const result = await analyzeRepository({
159 | path: nonExistentPath,
160 | depth: "standard",
161 | });
162 |
163 | expect((result as any).isError).toBe(true);
164 | expect(result.content[0].text).toContain("Error:");
165 | });
166 | });
167 |
168 | describe("recommend_ssg Tool", () => {
169 | it("should recommend SSG based on analysis", async () => {
170 | const result = await recommendSSG({
171 | analysisId: "test-analysis-123",
172 | });
173 |
174 | expect(result.content).toBeDefined();
175 | expect(result.content.length).toBeGreaterThan(0);
176 |
177 | // Should contain recommendation data
178 | const recommendationText = result.content.find((c) =>
179 | c.text.includes('"recommended"'),
180 | );
181 | expect(recommendationText).toBeDefined();
182 |
183 | const recommendation = JSON.parse(recommendationText!.text);
184 | expect(recommendation.recommended).toBeDefined();
185 | expect(recommendation.confidence).toBeGreaterThan(0);
186 | expect(recommendation.reasoning).toBeDefined();
187 | expect(recommendation.alternatives).toBeDefined();
188 | });
189 |
190 | it("should handle preferences parameter", async () => {
191 | const result = await recommendSSG({
192 | analysisId: "test-analysis-456",
193 | preferences: {
194 | priority: "simplicity",
195 | ecosystem: "javascript",
196 | },
197 | });
198 |
199 | expect(result.content).toBeDefined();
200 | const recommendationText = result.content.find((c) =>
201 | c.text.includes('"recommended"'),
202 | );
203 | const recommendation = JSON.parse(recommendationText!.text);
204 |
205 | expect(["jekyll", "hugo", "docusaurus", "mkdocs", "eleventy"]).toContain(
206 | recommendation.recommended,
207 | );
208 | });
209 | });
210 |
211 | describe("generate_config Tool", () => {
212 | let configOutputDir: string;
213 |
214 | beforeEach(async () => {
215 | configOutputDir = path.join(
216 | tempDir,
217 | "config-output",
218 | Date.now().toString(),
219 | );
220 | await fs.mkdir(configOutputDir, { recursive: true });
221 | });
222 |
223 | it("should generate Docusaurus configuration", async () => {
224 | const result = await generateConfig({
225 | ssg: "docusaurus",
226 | projectName: "Test Docusaurus Project",
227 | projectDescription: "A test project for Docusaurus",
228 | outputPath: configOutputDir,
229 | });
230 |
231 | expect(result.content).toBeDefined();
232 |
233 | // Verify files were created
234 | const docusaurusConfig = path.join(
235 | configOutputDir,
236 | "docusaurus.config.js",
237 | );
238 | const packageJson = path.join(configOutputDir, "package.json");
239 |
240 | expect(
241 | await fs
242 | .access(docusaurusConfig)
243 | .then(() => true)
244 | .catch(() => false),
245 | ).toBe(true);
246 | expect(
247 | await fs
248 | .access(packageJson)
249 | .then(() => true)
250 | .catch(() => false),
251 | ).toBe(true);
252 |
253 | // Verify file contents
254 | const configContent = await fs.readFile(docusaurusConfig, "utf-8");
255 | expect(configContent).toContain("Test Docusaurus Project");
256 | expect(configContent).toContain("classic");
257 | });
258 |
259 | it("should generate MkDocs configuration", async () => {
260 | const result = await generateConfig({
261 | ssg: "mkdocs",
262 | projectName: "Test MkDocs Project",
263 | outputPath: configOutputDir,
264 | });
265 |
266 | expect(result.content).toBeDefined();
267 |
268 | const mkdocsConfig = path.join(configOutputDir, "mkdocs.yml");
269 | const requirements = path.join(configOutputDir, "requirements.txt");
270 |
271 | expect(
272 | await fs
273 | .access(mkdocsConfig)
274 | .then(() => true)
275 | .catch(() => false),
276 | ).toBe(true);
277 | expect(
278 | await fs
279 | .access(requirements)
280 | .then(() => true)
281 | .catch(() => false),
282 | ).toBe(true);
283 |
284 | const configContent = await fs.readFile(mkdocsConfig, "utf-8");
285 | expect(configContent).toContain("Test MkDocs Project");
286 | expect(configContent).toContain("material");
287 | });
288 |
289 | it("should generate Hugo configuration", async () => {
290 | const result = await generateConfig({
291 | ssg: "hugo",
292 | projectName: "Test Hugo Project",
293 | outputPath: configOutputDir,
294 | });
295 |
296 | const hugoConfig = path.join(configOutputDir, "hugo.toml");
297 | expect(
298 | await fs
299 | .access(hugoConfig)
300 | .then(() => true)
301 | .catch(() => false),
302 | ).toBe(true);
303 |
304 | const configContent = await fs.readFile(hugoConfig, "utf-8");
305 | expect(configContent).toContain("Test Hugo Project");
306 | });
307 |
308 | it("should generate Jekyll configuration", async () => {
309 | const result = await generateConfig({
310 | ssg: "jekyll",
311 | projectName: "Test Jekyll Project",
312 | outputPath: configOutputDir,
313 | });
314 |
315 | const jekyllConfig = path.join(configOutputDir, "_config.yml");
316 | const gemfile = path.join(configOutputDir, "Gemfile");
317 |
318 | expect(
319 | await fs
320 | .access(jekyllConfig)
321 | .then(() => true)
322 | .catch(() => false),
323 | ).toBe(true);
324 | expect(
325 | await fs
326 | .access(gemfile)
327 | .then(() => true)
328 | .catch(() => false),
329 | ).toBe(true);
330 | });
331 |
332 | it("should generate Eleventy configuration", async () => {
333 | const result = await generateConfig({
334 | ssg: "eleventy",
335 | projectName: "Test Eleventy Project",
336 | outputPath: configOutputDir,
337 | });
338 |
339 | const eleventyConfig = path.join(configOutputDir, ".eleventy.js");
340 | const packageJson = path.join(configOutputDir, "package.json");
341 |
342 | expect(
343 | await fs
344 | .access(eleventyConfig)
345 | .then(() => true)
346 | .catch(() => false),
347 | ).toBe(true);
348 | expect(
349 | await fs
350 | .access(packageJson)
351 | .then(() => true)
352 | .catch(() => false),
353 | ).toBe(true);
354 | });
355 | });
356 |
357 | describe("setup_structure Tool", () => {
358 | let structureOutputDir: string;
359 |
360 | beforeEach(async () => {
361 | structureOutputDir = path.join(
362 | tempDir,
363 | "structure-output",
364 | Date.now().toString(),
365 | );
366 | });
367 |
368 | it("should create Diataxis structure with examples", async () => {
369 | const result = await setupStructure({
370 | path: structureOutputDir,
371 | ssg: "docusaurus",
372 | includeExamples: true,
373 | });
374 |
375 | expect(result.content).toBeDefined();
376 |
377 | // Verify directory structure
378 | const categories = ["tutorials", "how-to", "reference", "explanation"];
379 | for (const category of categories) {
380 | const categoryDir = path.join(structureOutputDir, category);
381 | expect(
382 | await fs
383 | .access(categoryDir)
384 | .then(() => true)
385 | .catch(() => false),
386 | ).toBe(true);
387 |
388 | // Check for index.md
389 | const indexFile = path.join(categoryDir, "index.md");
390 | expect(
391 | await fs
392 | .access(indexFile)
393 | .then(() => true)
394 | .catch(() => false),
395 | ).toBe(true);
396 |
397 | // Check for example file
398 | const files = await fs.readdir(categoryDir);
399 | expect(files.length).toBeGreaterThan(1); // index.md + example file
400 | }
401 |
402 | // Check root index
403 | const rootIndex = path.join(structureOutputDir, "index.md");
404 | expect(
405 | await fs
406 | .access(rootIndex)
407 | .then(() => true)
408 | .catch(() => false),
409 | ).toBe(true);
410 |
411 | const rootContent = await fs.readFile(rootIndex, "utf-8");
412 | expect(rootContent).toContain("Diataxis");
413 | expect(rootContent).toContain("Tutorials");
414 | expect(rootContent).toContain("How-To Guides");
415 | });
416 |
417 | it("should create structure without examples", async () => {
418 | const result = await setupStructure({
419 | path: structureOutputDir,
420 | ssg: "mkdocs",
421 | includeExamples: false,
422 | });
423 |
424 | expect(result.content).toBeDefined();
425 |
426 | // Verify only index files exist (no examples)
427 | const tutorialsDir = path.join(structureOutputDir, "tutorials");
428 | const files = await fs.readdir(tutorialsDir);
429 | expect(files).toEqual(["index.md"]); // Only index, no example
430 | });
431 |
432 | it("should handle different SSG formats correctly", async () => {
433 | // Test Docusaurus format
434 | await setupStructure({
435 | path: path.join(structureOutputDir, "docusaurus"),
436 | ssg: "docusaurus",
437 | includeExamples: true,
438 | });
439 |
440 | const docusaurusFile = path.join(
441 | structureOutputDir,
442 | "docusaurus",
443 | "tutorials",
444 | "index.md",
445 | );
446 | const docusaurusContent = await fs.readFile(docusaurusFile, "utf-8");
447 | expect(docusaurusContent).toContain("id: tutorials-index");
448 | expect(docusaurusContent).toContain("sidebar_label:");
449 |
450 | // Test Jekyll format
451 | await setupStructure({
452 | path: path.join(structureOutputDir, "jekyll"),
453 | ssg: "jekyll",
454 | includeExamples: true,
455 | });
456 |
457 | const jekyllFile = path.join(
458 | structureOutputDir,
459 | "jekyll",
460 | "tutorials",
461 | "index.md",
462 | );
463 | const jekyllContent = await fs.readFile(jekyllFile, "utf-8");
464 | expect(jekyllContent).toContain("title:");
465 | expect(jekyllContent).toContain("description:");
466 | });
467 | });
468 |
469 | describe("deploy_pages Tool", () => {
470 | let deploymentRepoDir: string;
471 |
472 | beforeEach(async () => {
473 | deploymentRepoDir = path.join(
474 | tempDir,
475 | "deployment-repo",
476 | Date.now().toString(),
477 | );
478 | await fs.mkdir(deploymentRepoDir, { recursive: true });
479 | });
480 |
481 | it("should create GitHub Actions workflow for Docusaurus", async () => {
482 | const result = await deployPages({
483 | repository: deploymentRepoDir,
484 | ssg: "docusaurus",
485 | branch: "gh-pages",
486 | });
487 |
488 | expect(result.content).toBeDefined();
489 |
490 | const workflowPath = path.join(
491 | deploymentRepoDir,
492 | ".github",
493 | "workflows",
494 | "deploy-docs.yml",
495 | );
496 | expect(
497 | await fs
498 | .access(workflowPath)
499 | .then(() => true)
500 | .catch(() => false),
501 | ).toBe(true);
502 |
503 | const workflowContent = await fs.readFile(workflowPath, "utf-8");
504 | expect(workflowContent).toContain("Deploy Docusaurus");
505 | expect(workflowContent).toContain("npm run build");
506 | expect(workflowContent).toContain("actions/upload-pages-artifact");
507 | expect(workflowContent).toContain("actions/deploy-pages");
508 |
509 | // Verify security compliance (OIDC tokens)
510 | expect(workflowContent).toContain("id-token: write");
511 | expect(workflowContent).toContain("pages: write");
512 | expect(workflowContent).not.toContain("GITHUB_TOKEN: ${{ secrets.");
513 | });
514 |
515 | it("should create workflow for MkDocs", async () => {
516 | const result = await deployPages({
517 | repository: deploymentRepoDir,
518 | ssg: "mkdocs",
519 | });
520 |
521 | const workflowPath = path.join(
522 | deploymentRepoDir,
523 | ".github",
524 | "workflows",
525 | "deploy-docs.yml",
526 | );
527 | const workflowContent = await fs.readFile(workflowPath, "utf-8");
528 |
529 | expect(workflowContent).toContain("Deploy MkDocs");
530 | expect(workflowContent).toContain("mkdocs gh-deploy");
531 | expect(workflowContent).toContain("python");
532 | });
533 |
534 | it("should create workflow for Hugo", async () => {
535 | const result = await deployPages({
536 | repository: deploymentRepoDir,
537 | ssg: "hugo",
538 | });
539 |
540 | const workflowContent = await fs.readFile(
541 | path.join(deploymentRepoDir, ".github", "workflows", "deploy-docs.yml"),
542 | "utf-8",
543 | );
544 |
545 | expect(workflowContent).toContain("Deploy Hugo");
546 | expect(workflowContent).toContain("peaceiris/actions-hugo");
547 | expect(workflowContent).toContain("hugo --minify");
548 | });
549 |
550 | it("should handle custom domain configuration", async () => {
551 | const result = await deployPages({
552 | repository: deploymentRepoDir,
553 | ssg: "jekyll",
554 | customDomain: "docs.example.com",
555 | });
556 |
557 | // Check CNAME file creation
558 | const cnamePath = path.join(deploymentRepoDir, "CNAME");
559 | expect(
560 | await fs
561 | .access(cnamePath)
562 | .then(() => true)
563 | .catch(() => false),
564 | ).toBe(true);
565 |
566 | const cnameContent = await fs.readFile(cnamePath, "utf-8");
567 | expect(cnameContent.trim()).toBe("docs.example.com");
568 |
569 | // Verify result indicates custom domain was configured
570 | const resultText = result.content.map((c) => c.text).join(" ");
571 | expect(resultText).toContain("docs.example.com");
572 | });
573 | });
574 |
575 | describe("verify_deployment Tool", () => {
576 | let verificationRepoDir: string;
577 |
578 | beforeEach(async () => {
579 | verificationRepoDir = path.join(
580 | tempDir,
581 | "verification-repo",
582 | Date.now().toString(),
583 | );
584 | await fs.mkdir(verificationRepoDir, { recursive: true });
585 | });
586 |
587 | it("should verify complete deployment setup", async () => {
588 | // Set up a complete deployment scenario
589 | await fs.mkdir(path.join(verificationRepoDir, ".github", "workflows"), {
590 | recursive: true,
591 | });
592 | await fs.mkdir(path.join(verificationRepoDir, "docs"), {
593 | recursive: true,
594 | });
595 | await fs.mkdir(path.join(verificationRepoDir, "build"), {
596 | recursive: true,
597 | });
598 |
599 | // Create workflow file
600 | await fs.writeFile(
601 | path.join(
602 | verificationRepoDir,
603 | ".github",
604 | "workflows",
605 | "deploy-docs.yml",
606 | ),
607 | "name: Deploy Docs\non: push\njobs:\n deploy:\n runs-on: ubuntu-latest",
608 | );
609 |
610 | // Create documentation files
611 | await fs.writeFile(
612 | path.join(verificationRepoDir, "docs", "index.md"),
613 | "# Documentation",
614 | );
615 | await fs.writeFile(
616 | path.join(verificationRepoDir, "docs", "guide.md"),
617 | "# Guide",
618 | );
619 |
620 | // Create config file
621 | await fs.writeFile(
622 | path.join(verificationRepoDir, "docusaurus.config.js"),
623 | 'module.exports = { title: "Test" };',
624 | );
625 |
626 | // Create build directory
627 | await fs.writeFile(
628 | path.join(verificationRepoDir, "build", "index.html"),
629 | "<h1>Built Site</h1>",
630 | );
631 |
632 | const result = await verifyDeployment({
633 | repository: verificationRepoDir,
634 | url: "https://example.github.io/test-repo",
635 | });
636 |
637 | expect(result.content).toBeDefined();
638 |
639 | // Parse the verification result
640 | const verification = JSON.parse(result.content[0].text);
641 | expect(verification.summary.passed).toBeGreaterThan(0); // Should have passing checks
642 | expect(
643 | verification.checks.some((check: any) =>
644 | check.message.includes("deployment workflow"),
645 | ),
646 | ).toBe(true);
647 | expect(
648 | verification.checks.some((check: any) =>
649 | check.message.includes("documentation files"),
650 | ),
651 | ).toBe(true);
652 | expect(
653 | verification.checks.some((check: any) =>
654 | check.message.includes("configuration"),
655 | ),
656 | ).toBe(true);
657 | expect(
658 | verification.checks.some((check: any) =>
659 | check.message.includes("build output"),
660 | ),
661 | ).toBe(true);
662 | });
663 |
664 | it("should identify missing components", async () => {
665 | // Create minimal repo without deployment setup
666 | await fs.writeFile(
667 | path.join(verificationRepoDir, "README.md"),
668 | "# Test Repo",
669 | );
670 |
671 | const result = await verifyDeployment({
672 | repository: verificationRepoDir,
673 | });
674 |
675 | const verification = JSON.parse(result.content[0].text);
676 | expect(verification.summary.failed).toBeGreaterThan(0); // Should have failing checks
677 | expect(
678 | verification.checks.some((check: any) =>
679 | check.message.includes("No .github/workflows"),
680 | ),
681 | ).toBe(true);
682 | expect(
683 | verification.checks.some((check: any) =>
684 | check.message.includes("No documentation files"),
685 | ),
686 | ).toBe(true);
687 | expect(
688 | verification.checks.some((check: any) =>
689 | check.message.includes("No static site generator configuration"),
690 | ),
691 | ).toBe(true);
692 | });
693 |
694 | it("should provide actionable recommendations", async () => {
695 | const result = await verifyDeployment({
696 | repository: verificationRepoDir,
697 | });
698 |
699 | const resultText = result.content.map((c) => c.text).join("\n");
700 | expect(resultText).toContain("→"); // Should contain recommendation arrows
701 | expect(resultText).toContain("deploy_pages tool");
702 | expect(resultText).toContain("setup_structure tool");
703 | expect(resultText).toContain("generate_config tool");
704 | });
705 |
706 | it("should handle repository path variations", async () => {
707 | // Test with relative path
708 | const relativeResult = await verifyDeployment({
709 | repository: ".",
710 | });
711 | expect(relativeResult.content).toBeDefined();
712 |
713 | // Test with absolute path
714 | const absoluteResult = await verifyDeployment({
715 | repository: verificationRepoDir,
716 | });
717 | expect(absoluteResult.content).toBeDefined();
718 |
719 | // Test with HTTP URL (should default to current directory)
720 | const urlResult = await verifyDeployment({
721 | repository: "https://github.com/user/repo",
722 | });
723 | expect(urlResult.content).toBeDefined();
724 | });
725 | });
726 |
727 | // Helper functions to create test repositories
728 | async function createJavaScriptRepo(): Promise<string> {
729 | const repoPath = path.join(tempDir, "javascript-repo");
730 | await fs.mkdir(repoPath, { recursive: true });
731 |
732 | // package.json
733 | const packageJson = {
734 | name: "test-js-project",
735 | version: "1.0.0",
736 | description: "Test JavaScript project",
737 | scripts: {
738 | start: "node index.js",
739 | test: "jest",
740 | },
741 | dependencies: {
742 | express: "^4.18.0",
743 | lodash: "^4.17.21",
744 | },
745 | devDependencies: {
746 | jest: "^29.0.0",
747 | "@types/node": "^20.0.0",
748 | },
749 | };
750 | await fs.writeFile(
751 | path.join(repoPath, "package.json"),
752 | JSON.stringify(packageJson, null, 2),
753 | );
754 |
755 | // Source files
756 | await fs.writeFile(
757 | path.join(repoPath, "index.js"),
758 | 'console.log("Hello World");',
759 | );
760 | await fs.writeFile(
761 | path.join(repoPath, "utils.js"),
762 | "module.exports = { helper: () => {} };",
763 | );
764 | await fs.writeFile(
765 | path.join(repoPath, "app.ts"),
766 | 'const app: string = "TypeScript";',
767 | );
768 |
769 | // Test directory
770 | await fs.mkdir(path.join(repoPath, "test"), { recursive: true });
771 | await fs.writeFile(
772 | path.join(repoPath, "test", "app.test.js"),
773 | 'test("example", () => {});',
774 | );
775 |
776 | // Documentation
777 | await fs.writeFile(
778 | path.join(repoPath, "README.md"),
779 | "# JavaScript Test Project\nA test project for JavaScript analysis.",
780 | );
781 | await fs.writeFile(
782 | path.join(repoPath, "CONTRIBUTING.md"),
783 | "# Contributing\nHow to contribute.",
784 | );
785 | await fs.writeFile(path.join(repoPath, "LICENSE"), "MIT License");
786 |
787 | // CI/CD
788 | await fs.mkdir(path.join(repoPath, ".github", "workflows"), {
789 | recursive: true,
790 | });
791 | await fs.writeFile(
792 | path.join(repoPath, ".github", "workflows", "ci.yml"),
793 | "name: CI\non: push\njobs:\n test:\n runs-on: ubuntu-latest",
794 | );
795 |
796 | return repoPath;
797 | }
798 |
799 | async function createPythonRepo(): Promise<string> {
800 | const repoPath = path.join(tempDir, "python-repo");
801 | await fs.mkdir(repoPath, { recursive: true });
802 |
803 | // requirements.txt
804 | await fs.writeFile(
805 | path.join(repoPath, "requirements.txt"),
806 | "flask>=2.0.0\nrequests>=2.25.0\nnumpy>=1.21.0",
807 | );
808 |
809 | // Python files
810 | await fs.writeFile(
811 | path.join(repoPath, "main.py"),
812 | "import flask\napp = flask.Flask(__name__)",
813 | );
814 | await fs.writeFile(
815 | path.join(repoPath, "utils.py"),
816 | "def helper():\n pass",
817 | );
818 |
819 | // Tests
820 | await fs.mkdir(path.join(repoPath, "tests"), { recursive: true });
821 | await fs.writeFile(
822 | path.join(repoPath, "tests", "test_main.py"),
823 | "def test_app():\n assert True",
824 | );
825 |
826 | await fs.writeFile(
827 | path.join(repoPath, "README.md"),
828 | "# Python Test Project",
829 | );
830 |
831 | return repoPath;
832 | }
833 |
834 | async function createRubyRepo(): Promise<string> {
835 | const repoPath = path.join(tempDir, "ruby-repo");
836 | await fs.mkdir(repoPath, { recursive: true });
837 |
838 | // Gemfile
839 | await fs.writeFile(
840 | path.join(repoPath, "Gemfile"),
841 | 'source "https://rubygems.org"\ngem "rails"',
842 | );
843 |
844 | // Ruby files
845 | await fs.writeFile(path.join(repoPath, "app.rb"), "class App\nend");
846 | await fs.writeFile(path.join(repoPath, "helper.rb"), "module Helper\nend");
847 |
848 | await fs.writeFile(path.join(repoPath, "README.md"), "# Ruby Test Project");
849 |
850 | return repoPath;
851 | }
852 |
853 | async function createGoRepo(): Promise<string> {
854 | const repoPath = path.join(tempDir, "go-repo");
855 | await fs.mkdir(repoPath, { recursive: true });
856 |
857 | // go.mod
858 | await fs.writeFile(
859 | path.join(repoPath, "go.mod"),
860 | "module test-go-project\ngo 1.19",
861 | );
862 |
863 | // Go files
864 | await fs.writeFile(
865 | path.join(repoPath, "main.go"),
866 | "package main\nfunc main() {}",
867 | );
868 | await fs.writeFile(
869 | path.join(repoPath, "utils.go"),
870 | "package main\nfunc helper() {}",
871 | );
872 |
873 | await fs.writeFile(path.join(repoPath, "README.md"), "# Go Test Project");
874 |
875 | return repoPath;
876 | }
877 |
878 | async function createMixedLanguageRepo(): Promise<string> {
879 | const repoPath = path.join(tempDir, "mixed-repo");
880 | await fs.mkdir(repoPath, { recursive: true });
881 |
882 | // Multiple language files
883 | await fs.writeFile(
884 | path.join(repoPath, "package.json"),
885 | '{"name": "mixed-project"}',
886 | );
887 | await fs.writeFile(path.join(repoPath, "requirements.txt"), "flask>=2.0.0");
888 | await fs.writeFile(path.join(repoPath, "Gemfile"), 'gem "rails"');
889 |
890 | await fs.writeFile(path.join(repoPath, "app.js"), 'console.log("JS");');
891 | await fs.writeFile(path.join(repoPath, "script.py"), 'print("Python")');
892 | await fs.writeFile(path.join(repoPath, "server.rb"), 'puts "Ruby"');
893 |
894 | await fs.writeFile(
895 | path.join(repoPath, "README.md"),
896 | "# Mixed Language Project",
897 | );
898 |
899 | return repoPath;
900 | }
901 |
902 | async function createLargeRepo(): Promise<string> {
903 | const repoPath = path.join(tempDir, "large-repo");
904 | await fs.mkdir(repoPath, { recursive: true });
905 |
906 | // Create many files to simulate a large repository
907 | for (let i = 0; i < 150; i++) {
908 | const fileName = `file-${i.toString().padStart(3, "0")}.js`;
909 | await fs.writeFile(
910 | path.join(repoPath, fileName),
911 | `// File ${i}\nconsole.log(${i});`,
912 | );
913 | }
914 |
915 | // Create nested directories
916 | for (let i = 0; i < 10; i++) {
917 | const dirPath = path.join(repoPath, `dir-${i}`);
918 | await fs.mkdir(dirPath, { recursive: true });
919 |
920 | for (let j = 0; j < 20; j++) {
921 | const fileName = `nested-${j}.js`;
922 | await fs.writeFile(
923 | path.join(dirPath, fileName),
924 | `// Nested file ${i}-${j}`,
925 | );
926 | }
927 | }
928 |
929 | await fs.writeFile(
930 | path.join(repoPath, "package.json"),
931 | '{"name": "large-project"}',
932 | );
933 | await fs.writeFile(
934 | path.join(repoPath, "README.md"),
935 | "# Large Test Project",
936 | );
937 |
938 | return repoPath;
939 | }
940 |
941 | async function createEmptyRepo(): Promise<string> {
942 | const repoPath = path.join(tempDir, "empty-repo");
943 | await fs.mkdir(repoPath, { recursive: true });
944 |
945 | // Only a README file
946 | await fs.writeFile(
947 | path.join(repoPath, "README.md"),
948 | "# Empty Project\nMinimal repository for testing.",
949 | );
950 |
951 | return repoPath;
952 | }
953 | });
954 |
```