This is page 9 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/memory/kg-link-validator.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { promises as fs } from "fs";
2 | import path from "path";
3 | import os from "os";
4 | import {
5 | validateExternalLinks,
6 | validateAndStoreDocumentationLinks,
7 | extractLinksFromContent,
8 | storeLinkValidationInKG,
9 | getLinkValidationHistory,
10 | } from "../../src/memory/kg-link-validator";
11 | import { getKnowledgeGraph } from "../../src/memory/kg-integration";
12 |
13 | describe("KG Link Validator", () => {
14 | let tempDir: string;
15 | const originalCwd = process.cwd();
16 |
17 | beforeEach(async () => {
18 | tempDir = path.join(os.tmpdir(), `kg-link-${Date.now()}`);
19 | await fs.mkdir(tempDir, { recursive: true });
20 | process.chdir(tempDir);
21 | });
22 |
23 | afterEach(async () => {
24 | process.chdir(originalCwd);
25 | try {
26 | await fs.rm(tempDir, { recursive: true, force: true });
27 | } catch {
28 | // Ignore cleanup errors
29 | }
30 | });
31 |
32 | describe("validateExternalLinks", () => {
33 | it("should validate valid URLs", async () => {
34 | const urls = ["https://www.google.com", "https://github.com"];
35 |
36 | const result = await validateExternalLinks(urls, {
37 | timeout: 5000,
38 | });
39 |
40 | expect(result.totalLinks).toBe(2);
41 | expect(result.results).toHaveLength(2);
42 | expect(result.validLinks + result.brokenLinks + result.unknownLinks).toBe(
43 | 2,
44 | );
45 | });
46 |
47 | it("should detect broken links", async () => {
48 | const urls = ["https://this-domain-definitely-does-not-exist-12345.com"];
49 |
50 | const result = await validateExternalLinks(urls, {
51 | timeout: 3000,
52 | });
53 |
54 | expect(result.totalLinks).toBe(1);
55 | expect(result.results).toHaveLength(1);
56 | // Should be either broken or unknown
57 | expect(result.brokenLinks + result.unknownLinks).toBeGreaterThan(0);
58 | });
59 |
60 | it("should handle empty URL list", async () => {
61 | const result = await validateExternalLinks([]);
62 |
63 | expect(result.totalLinks).toBe(0);
64 | expect(result.results).toHaveLength(0);
65 | });
66 |
67 | it("should respect timeout option", async () => {
68 | const urls = ["https://www.google.com"];
69 |
70 | const startTime = Date.now();
71 | await validateExternalLinks(urls, {
72 | timeout: 1000,
73 | });
74 | const duration = Date.now() - startTime;
75 |
76 | // Should complete reasonably quickly
77 | expect(duration).toBeLessThan(10000);
78 | });
79 |
80 | it("should use default timeout when not provided", async () => {
81 | const urls = ["https://www.google.com"];
82 |
83 | const result = await validateExternalLinks(urls);
84 |
85 | expect(result).toBeDefined();
86 | expect(result.totalLinks).toBe(1);
87 | });
88 |
89 | it("should handle validation errors gracefully", async () => {
90 | const urls = ["https://httpstat.us/500"]; // Returns 500 error
91 |
92 | const result = await validateExternalLinks(urls, {
93 | timeout: 5000,
94 | });
95 |
96 | expect(result.totalLinks).toBe(1);
97 | // Should be marked as broken or unknown
98 | expect(result.brokenLinks + result.unknownLinks).toBeGreaterThan(0);
99 | });
100 |
101 | it("should count warning links correctly", async () => {
102 | const urls = ["https://httpstat.us/301"]; // Redirect
103 |
104 | const result = await validateExternalLinks(urls, {
105 | timeout: 5000,
106 | });
107 |
108 | expect(result.totalLinks).toBe(1);
109 | // Should handle redirects as valid (fetch follows redirects)
110 | expect(
111 | result.validLinks +
112 | result.brokenLinks +
113 | result.warningLinks +
114 | result.unknownLinks,
115 | ).toBe(1);
116 | });
117 |
118 | it("should handle network errors in validation loop", async () => {
119 | const urls = ["https://invalid-url-12345.test", "https://www.google.com"];
120 |
121 | const result = await validateExternalLinks(urls, {
122 | timeout: 3000,
123 | });
124 |
125 | expect(result.totalLinks).toBe(2);
126 | expect(result.results).toHaveLength(2);
127 | });
128 |
129 | it("should include response time in valid results", async () => {
130 | const urls = ["https://www.google.com"];
131 |
132 | const result = await validateExternalLinks(urls, {
133 | timeout: 5000,
134 | });
135 |
136 | expect(result.results[0].lastChecked).toBeDefined();
137 | if (result.results[0].status === "valid") {
138 | expect(result.results[0].responseTime).toBeDefined();
139 | expect(result.results[0].responseTime).toBeGreaterThan(0);
140 | }
141 | });
142 |
143 | it("should include response time in broken results", async () => {
144 | const urls = ["https://httpstat.us/404"];
145 |
146 | const result = await validateExternalLinks(urls, {
147 | timeout: 5000,
148 | });
149 |
150 | expect(result.results[0].lastChecked).toBeDefined();
151 | if (
152 | result.results[0].status === "broken" &&
153 | result.results[0].statusCode
154 | ) {
155 | expect(result.results[0].responseTime).toBeDefined();
156 | }
157 | });
158 | });
159 |
160 | describe("extractLinksFromContent", () => {
161 | it("should extract external links", () => {
162 | const content = `
163 | # Test
164 | [Google](https://www.google.com)
165 | [GitHub](https://github.com)
166 | `;
167 |
168 | const result = extractLinksFromContent(content);
169 |
170 | expect(result.externalLinks.length).toBeGreaterThan(0);
171 | });
172 |
173 | it("should extract internal links", () => {
174 | const content = `
175 | # Test
176 | [Page 1](./page1.md)
177 | [Page 2](../page2.md)
178 | `;
179 |
180 | const result = extractLinksFromContent(content);
181 |
182 | expect(result.internalLinks.length).toBeGreaterThan(0);
183 | });
184 |
185 | it("should handle mixed links", () => {
186 | const content = `
187 | # Test
188 | [External](https://example.com)
189 | [Internal](./page.md)
190 | `;
191 |
192 | const result = extractLinksFromContent(content);
193 |
194 | expect(result.externalLinks.length).toBeGreaterThan(0);
195 | expect(result.internalLinks.length).toBeGreaterThan(0);
196 | });
197 |
198 | it("should extract HTTP links", () => {
199 | const content = `[Link](http://example.com)`;
200 |
201 | const result = extractLinksFromContent(content);
202 |
203 | expect(result.externalLinks).toContain("http://example.com");
204 | });
205 |
206 | it("should extract HTML anchor links", () => {
207 | const content = `<a href="https://example.com">Link</a>`;
208 |
209 | const result = extractLinksFromContent(content);
210 |
211 | expect(result.externalLinks).toContain("https://example.com");
212 | });
213 |
214 | it("should extract HTML anchor links with single quotes", () => {
215 | const content = `<a href='https://example.com'>Link</a>`;
216 |
217 | const result = extractLinksFromContent(content);
218 |
219 | expect(result.externalLinks).toContain("https://example.com");
220 | });
221 |
222 | it("should extract internal HTML links", () => {
223 | const content = `<a href="./page.md">Link</a>`;
224 |
225 | const result = extractLinksFromContent(content);
226 |
227 | expect(result.internalLinks).toContain("./page.md");
228 | });
229 |
230 | it("should remove duplicate links", () => {
231 | const content = `
232 | [Link1](https://example.com)
233 | [Link2](https://example.com)
234 | [Link3](./page.md)
235 | [Link4](./page.md)
236 | `;
237 |
238 | const result = extractLinksFromContent(content);
239 |
240 | expect(result.externalLinks.length).toBe(1);
241 | expect(result.internalLinks.length).toBe(1);
242 | });
243 |
244 | it("should handle content with no links", () => {
245 | const content = "# Test\nNo links here";
246 |
247 | const result = extractLinksFromContent(content);
248 |
249 | expect(result.externalLinks).toEqual([]);
250 | expect(result.internalLinks).toEqual([]);
251 | });
252 | });
253 |
254 | describe("validateAndStoreDocumentationLinks", () => {
255 | it("should validate and store documentation links", async () => {
256 | const content =
257 | "# Test\n[Link](./other.md)\n[External](https://example.com)";
258 |
259 | const result = await validateAndStoreDocumentationLinks(
260 | "test-project",
261 | content,
262 | );
263 |
264 | expect(result).toBeDefined();
265 | expect(result.totalLinks).toBeGreaterThan(0);
266 | });
267 |
268 | it("should handle documentation without links", async () => {
269 | const content = "# Test\nNo links here";
270 |
271 | const result = await validateAndStoreDocumentationLinks(
272 | "test-project",
273 | content,
274 | );
275 |
276 | expect(result).toBeDefined();
277 | expect(result.totalLinks).toBe(0);
278 | });
279 |
280 | it("should handle content with only internal links", async () => {
281 | const content = "# Test\n[Page](./page.md)";
282 |
283 | const result = await validateAndStoreDocumentationLinks(
284 | "test-project",
285 | content,
286 | );
287 |
288 | expect(result).toBeDefined();
289 | // Only external links are validated
290 | expect(result.totalLinks).toBe(0);
291 | });
292 | });
293 |
294 | describe("storeLinkValidationInKG", () => {
295 | it("should store validation results with no broken links", async () => {
296 | const summary = {
297 | totalLinks: 5,
298 | validLinks: 5,
299 | brokenLinks: 0,
300 | warningLinks: 0,
301 | unknownLinks: 0,
302 | results: [],
303 | };
304 |
305 | await storeLinkValidationInKG("doc-section-1", summary);
306 |
307 | const kg = await getKnowledgeGraph();
308 | const nodes = await kg.getAllNodes();
309 |
310 | // Find the specific validation node for this test
311 | const validationNode = nodes.find(
312 | (n) =>
313 | n.type === "link_validation" &&
314 | n.properties.totalLinks === 5 &&
315 | n.properties.brokenLinks === 0,
316 | );
317 | expect(validationNode).toBeDefined();
318 | expect(validationNode?.properties.totalLinks).toBe(5);
319 | expect(validationNode?.properties.healthScore).toBe(100);
320 | });
321 |
322 | it("should store validation results with broken links", async () => {
323 | const summary = {
324 | totalLinks: 10,
325 | validLinks: 7,
326 | brokenLinks: 3,
327 | warningLinks: 0,
328 | unknownLinks: 0,
329 | results: [
330 | {
331 | url: "https://broken1.com",
332 | status: "broken" as const,
333 | lastChecked: new Date().toISOString(),
334 | },
335 | {
336 | url: "https://broken2.com",
337 | status: "broken" as const,
338 | lastChecked: new Date().toISOString(),
339 | },
340 | {
341 | url: "https://broken3.com",
342 | status: "broken" as const,
343 | lastChecked: new Date().toISOString(),
344 | },
345 | ],
346 | };
347 |
348 | await storeLinkValidationInKG("doc-section-2", summary);
349 |
350 | const kg = await getKnowledgeGraph();
351 | const edges = await kg.findEdges({
352 | source: "doc-section-2",
353 | type: "has_link_validation",
354 | });
355 |
356 | expect(edges.length).toBeGreaterThan(0);
357 | });
358 |
359 | it("should create requires_fix edge for broken links", async () => {
360 | const summary = {
361 | totalLinks: 10,
362 | validLinks: 4,
363 | brokenLinks: 6,
364 | warningLinks: 0,
365 | unknownLinks: 0,
366 | results: [
367 | {
368 | url: "https://broken.com",
369 | status: "broken" as const,
370 | lastChecked: new Date().toISOString(),
371 | },
372 | ],
373 | };
374 |
375 | await storeLinkValidationInKG("doc-section-3", summary);
376 |
377 | const kg = await getKnowledgeGraph();
378 | const allNodes = await kg.getAllNodes();
379 | const validationNode = allNodes.find(
380 | (n) => n.type === "link_validation" && n.properties.brokenLinks === 6,
381 | );
382 |
383 | expect(validationNode).toBeDefined();
384 |
385 | const requiresFixEdges = await kg.findEdges({
386 | source: validationNode!.id,
387 | type: "requires_fix",
388 | });
389 |
390 | expect(requiresFixEdges.length).toBeGreaterThan(0);
391 | expect(requiresFixEdges[0].properties.severity).toBe("high"); // > 5 broken links
392 | });
393 |
394 | it("should set medium severity for few broken links", async () => {
395 | const summary = {
396 | totalLinks: 10,
397 | validLinks: 8,
398 | brokenLinks: 2,
399 | warningLinks: 0,
400 | unknownLinks: 0,
401 | results: [
402 | {
403 | url: "https://broken.com",
404 | status: "broken" as const,
405 | lastChecked: new Date().toISOString(),
406 | },
407 | ],
408 | };
409 |
410 | await storeLinkValidationInKG("doc-section-4", summary);
411 |
412 | const kg = await getKnowledgeGraph();
413 | const allNodes = await kg.getAllNodes();
414 | const validationNode = allNodes.find(
415 | (n) => n.type === "link_validation" && n.properties.brokenLinks === 2,
416 | );
417 |
418 | const requiresFixEdges = await kg.findEdges({
419 | source: validationNode!.id,
420 | type: "requires_fix",
421 | });
422 |
423 | expect(requiresFixEdges[0].properties.severity).toBe("medium");
424 | });
425 |
426 | it("should calculate health score correctly", async () => {
427 | const summary = {
428 | totalLinks: 20,
429 | validLinks: 15,
430 | brokenLinks: 5,
431 | warningLinks: 0,
432 | unknownLinks: 0,
433 | results: [],
434 | };
435 |
436 | await storeLinkValidationInKG("doc-section-5", summary);
437 |
438 | const kg = await getKnowledgeGraph();
439 | const nodes = await kg.getAllNodes();
440 |
441 | const validationNode = nodes.find(
442 | (n) => n.type === "link_validation" && n.properties.totalLinks === 20,
443 | );
444 |
445 | expect(validationNode?.properties.healthScore).toBe(75);
446 | });
447 |
448 | it("should handle zero links with 100% health score", async () => {
449 | const summary = {
450 | totalLinks: 0,
451 | validLinks: 0,
452 | brokenLinks: 0,
453 | warningLinks: 0,
454 | unknownLinks: 0,
455 | results: [],
456 | };
457 |
458 | await storeLinkValidationInKG("doc-section-6", summary);
459 |
460 | const kg = await getKnowledgeGraph();
461 | const nodes = await kg.getAllNodes();
462 |
463 | const validationNode = nodes.find(
464 | (n) => n.type === "link_validation" && n.properties.totalLinks === 0,
465 | );
466 |
467 | expect(validationNode?.properties.healthScore).toBe(100);
468 | });
469 | });
470 |
471 | describe("getLinkValidationHistory", () => {
472 | it("should retrieve validation history", async () => {
473 | const summary1 = {
474 | totalLinks: 5,
475 | validLinks: 5,
476 | brokenLinks: 0,
477 | warningLinks: 0,
478 | unknownLinks: 0,
479 | results: [],
480 | };
481 |
482 | await storeLinkValidationInKG("doc-section-7", summary1);
483 |
484 | const history = await getLinkValidationHistory("doc-section-7");
485 |
486 | expect(history.length).toBeGreaterThan(0);
487 | expect(history[0].type).toBe("link_validation");
488 | });
489 |
490 | it("should return empty array for non-existent doc section", async () => {
491 | const history = await getLinkValidationHistory("non-existent");
492 |
493 | expect(history).toEqual([]);
494 | });
495 |
496 | it("should sort history by newest first", async () => {
497 | // Add two validations with delay to ensure different timestamps
498 | const summary1 = {
499 | totalLinks: 5,
500 | validLinks: 5,
501 | brokenLinks: 0,
502 | warningLinks: 0,
503 | unknownLinks: 0,
504 | results: [],
505 | };
506 |
507 | await storeLinkValidationInKG("doc-section-8", summary1);
508 |
509 | // Small delay to ensure different timestamp
510 | await new Promise((resolve) => setTimeout(resolve, 10));
511 |
512 | const summary2 = {
513 | totalLinks: 6,
514 | validLinks: 6,
515 | brokenLinks: 0,
516 | warningLinks: 0,
517 | unknownLinks: 0,
518 | results: [],
519 | };
520 |
521 | await storeLinkValidationInKG("doc-section-8", summary2);
522 |
523 | const history = await getLinkValidationHistory("doc-section-8");
524 |
525 | expect(history.length).toBeGreaterThan(1);
526 | // First item should be newest
527 | const firstTimestamp = new Date(
528 | history[0].properties.lastValidated,
529 | ).getTime();
530 | const secondTimestamp = new Date(
531 | history[1].properties.lastValidated,
532 | ).getTime();
533 | expect(firstTimestamp).toBeGreaterThanOrEqual(secondTimestamp);
534 | });
535 | });
536 | });
537 |
```
--------------------------------------------------------------------------------
/tests/memory/manager-advanced.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for uncovered branches in Memory Manager
3 | * Covers: getRelated (lines 171-202), export (lines 381-398), import (lines 409-415)
4 | */
5 |
6 | import { promises as fs } from "fs";
7 | import path from "path";
8 | import os from "os";
9 | import { MemoryManager } from "../../src/memory/manager.js";
10 | import { MemoryEntry } from "../../src/memory/storage.js";
11 |
12 | describe("MemoryManager - Advanced Features Coverage", () => {
13 | let manager: MemoryManager;
14 | let tempDir: string;
15 |
16 | beforeEach(async () => {
17 | tempDir = path.join(
18 | os.tmpdir(),
19 | `manager-advanced-test-${Date.now()}-${Math.random()
20 | .toString(36)
21 | .substr(2, 9)}`,
22 | );
23 | await fs.mkdir(tempDir, { recursive: true });
24 | manager = new MemoryManager(tempDir);
25 | await manager.initialize();
26 | });
27 |
28 | afterEach(async () => {
29 | try {
30 | await manager.close();
31 | await fs.rm(tempDir, { recursive: true, force: true });
32 | } catch (error) {
33 | // Ignore cleanup errors
34 | }
35 | });
36 |
37 | describe("getRelated - Tag-based Relationships (lines 189-195)", () => {
38 | it("should find related memories by overlapping tags", async () => {
39 | // Create entries with overlapping tags
40 | const entry1 = await manager.remember(
41 | "analysis",
42 | { name: "Project A" },
43 | {
44 | projectId: "proj-001",
45 | tags: ["typescript", "react", "frontend"],
46 | },
47 | );
48 |
49 | await manager.remember(
50 | "analysis",
51 | { name: "Project B" },
52 | {
53 | projectId: "proj-002",
54 | tags: ["typescript", "vue", "frontend"],
55 | },
56 | );
57 |
58 | await manager.remember(
59 | "analysis",
60 | { name: "Project C" },
61 | {
62 | projectId: "proj-003",
63 | tags: ["python", "backend"],
64 | },
65 | );
66 |
67 | // Get related memories for entry1 (should find project B via overlapping tags)
68 | const related = await manager.getRelated(entry1, 10);
69 |
70 | expect(related.length).toBeGreaterThan(0);
71 |
72 | // Should include Project B (shares typescript and frontend tags)
73 | const relatedNames = related.map((r) => r.data.name);
74 | expect(relatedNames).toContain("Project B");
75 |
76 | // Should not include entry1 itself
77 | expect(relatedNames).not.toContain("Project A");
78 | });
79 |
80 | it("should find related memories by same type (lines 182-186)", async () => {
81 | const entry1 = await manager.remember(
82 | "recommendation",
83 | { ssg: "jekyll" },
84 | { projectId: "proj-001" },
85 | );
86 |
87 | await manager.remember(
88 | "recommendation",
89 | { ssg: "hugo" },
90 | { projectId: "proj-002" },
91 | );
92 |
93 | await manager.remember(
94 | "analysis",
95 | { type: "different" },
96 | { projectId: "proj-003" },
97 | );
98 |
99 | const related = await manager.getRelated(entry1, 10);
100 |
101 | // Should find the other recommendation, not the analysis
102 | expect(related.length).toBeGreaterThan(0);
103 | const types = related.map((r) => r.type);
104 | expect(types).toContain("recommendation");
105 | });
106 |
107 | it("should find related memories by same project (lines 174-179)", async () => {
108 | manager.setContext({ projectId: "shared-project" });
109 |
110 | const entry1 = await manager.remember(
111 | "analysis",
112 | { step: "step1" },
113 | { projectId: "shared-project" },
114 | );
115 |
116 | await manager.remember(
117 | "analysis",
118 | { step: "step2" },
119 | { projectId: "shared-project" },
120 | );
121 |
122 | await manager.remember(
123 | "analysis",
124 | { step: "step3" },
125 | { projectId: "different-project" },
126 | );
127 |
128 | const related = await manager.getRelated(entry1, 10);
129 |
130 | // Should find step2 from same project
131 | expect(related.length).toBeGreaterThan(0);
132 | const projectIds = related.map((r) => r.metadata.projectId);
133 | expect(projectIds).toContain("shared-project");
134 | });
135 |
136 | it("should deduplicate and limit related memories (lines 198-202)", async () => {
137 | const entry1 = await manager.remember(
138 | "analysis",
139 | { name: "Entry 1" },
140 | {
141 | projectId: "proj-001",
142 | tags: ["tag1", "tag2"],
143 | },
144 | );
145 |
146 | // Create many related entries
147 | for (let i = 0; i < 20; i++) {
148 | await manager.remember(
149 | "analysis",
150 | { name: `Entry ${i + 2}` },
151 | {
152 | projectId: "proj-001",
153 | tags: i < 10 ? ["tag1"] : ["tag2"],
154 | },
155 | );
156 | }
157 |
158 | // Request limit of 5
159 | const related = await manager.getRelated(entry1, 5);
160 |
161 | // Should be limited to 5 (deduplicated)
162 | expect(related.length).toBeLessThanOrEqual(5);
163 |
164 | // Should not include entry1 itself
165 | const names = related.map((r) => r.data.name);
166 | expect(names).not.toContain("Entry 1");
167 | });
168 |
169 | it("should handle entry without tags gracefully (line 189)", async () => {
170 | const entryNoTags = await manager.remember(
171 | "analysis",
172 | { name: "No Tags" },
173 | { projectId: "proj-001" },
174 | );
175 |
176 | await manager.remember(
177 | "analysis",
178 | { name: "Also No Tags" },
179 | { projectId: "proj-001" },
180 | );
181 |
182 | // Should still find related by project
183 | const related = await manager.getRelated(entryNoTags, 10);
184 | expect(related.length).toBeGreaterThan(0);
185 | });
186 |
187 | it("should handle entry with empty tags array (line 189)", async () => {
188 | const entryEmptyTags = await manager.remember(
189 | "analysis",
190 | { name: "Empty Tags" },
191 | {
192 | projectId: "proj-001",
193 | tags: [],
194 | },
195 | );
196 |
197 | await manager.remember(
198 | "analysis",
199 | { name: "Other Entry" },
200 | { projectId: "proj-001" },
201 | );
202 |
203 | const related = await manager.getRelated(entryEmptyTags, 10);
204 | expect(related.length).toBeGreaterThan(0);
205 | });
206 | });
207 |
208 | describe("CSV Export (lines 381-398)", () => {
209 | it("should export memories as CSV format", async () => {
210 | manager.setContext({ projectId: "csv-proj-001" });
211 |
212 | await manager.remember(
213 | "analysis",
214 | { test: "data1" },
215 | {
216 | repository: "github.com/test/repo1",
217 | ssg: "jekyll",
218 | },
219 | );
220 |
221 | manager.setContext({ projectId: "csv-proj-002" });
222 |
223 | await manager.remember(
224 | "recommendation",
225 | { test: "data2" },
226 | {
227 | repository: "github.com/test/repo2",
228 | ssg: "hugo",
229 | },
230 | );
231 |
232 | // Export as CSV
233 | const csvData = await manager.export("csv");
234 |
235 | // Verify CSV structure
236 | expect(csvData).toContain("id,timestamp,type,projectId,repository,ssg");
237 | expect(csvData).toContain("csv-proj-001");
238 | expect(csvData).toContain("csv-proj-002");
239 | expect(csvData).toContain("github.com/test/repo1");
240 | expect(csvData).toContain("github.com/test/repo2");
241 | expect(csvData).toContain("jekyll");
242 | expect(csvData).toContain("hugo");
243 |
244 | // Verify rows are comma-separated
245 | const lines = csvData.split("\n").filter((l) => l.trim());
246 | expect(lines.length).toBeGreaterThanOrEqual(3); // header + 2 rows
247 |
248 | // Each line should have the same number of commas
249 | const headerCommas = (lines[0].match(/,/g) || []).length;
250 | for (let i = 1; i < lines.length; i++) {
251 | const rowCommas = (lines[i].match(/,/g) || []).length;
252 | expect(rowCommas).toBe(headerCommas);
253 | }
254 | });
255 |
256 | it("should export memories for specific project only", async () => {
257 | manager.setContext({ projectId: "project-a" });
258 | await manager.remember("analysis", { project: "A" }, {});
259 |
260 | manager.setContext({ projectId: "project-b" });
261 | await manager.remember("analysis", { project: "B" }, {});
262 |
263 | // Export only project-a
264 | const csvData = await manager.export("csv", "project-a");
265 |
266 | expect(csvData).toContain("project-a");
267 | expect(csvData).not.toContain("project-b");
268 | });
269 |
270 | it("should handle missing metadata fields in CSV export (lines 393-395)", async () => {
271 | // Create entry with minimal metadata
272 | await manager.remember("analysis", { test: "minimal" }, {});
273 |
274 | const csvData = await manager.export("csv");
275 |
276 | // Should have empty fields for missing metadata
277 | const lines = csvData.split("\n");
278 | expect(lines.length).toBeGreaterThan(1);
279 |
280 | // Verify header
281 | expect(lines[0]).toContain("id,timestamp,type,projectId,repository,ssg");
282 |
283 | // Data row should have appropriate number of commas (empty fields)
284 | const dataRow = lines[1];
285 | const headerCommas = (lines[0].match(/,/g) || []).length;
286 | const dataCommas = (dataRow.match(/,/g) || []).length;
287 | expect(dataCommas).toBe(headerCommas);
288 | });
289 |
290 | it("should export as JSON by default", async () => {
291 | await manager.remember(
292 | "analysis",
293 | { json: "test" },
294 | { projectId: "json-proj" },
295 | );
296 |
297 | const jsonData = await manager.export("json");
298 |
299 | const parsed = JSON.parse(jsonData);
300 | expect(Array.isArray(parsed)).toBe(true);
301 | expect(parsed.length).toBeGreaterThan(0);
302 | expect(parsed[0].data.json).toBe("test");
303 | });
304 | });
305 |
306 | describe("CSV Import (lines 409-428)", () => {
307 | it("should import memories from CSV format", async () => {
308 | // Create CSV data
309 | const csvData = `id,timestamp,type,projectId,repository,ssg
310 | mem-001,2024-01-01T00:00:00.000Z,analysis,proj-csv-001,github.com/test/repo1,jekyll
311 | mem-002,2024-01-02T00:00:00.000Z,recommendation,proj-csv-002,github.com/test/repo2,hugo
312 | mem-003,2024-01-03T00:00:00.000Z,deployment,proj-csv-003,github.com/test/repo3,mkdocs`;
313 |
314 | const imported = await manager.import(csvData, "csv");
315 |
316 | expect(imported).toBe(3);
317 |
318 | // Verify entries were imported
319 | const recalled1 = await manager.recall("mem-001");
320 | expect(recalled1).not.toBeNull();
321 | expect(recalled1?.type).toBe("analysis");
322 | expect(recalled1?.metadata.projectId).toBe("proj-csv-001");
323 | expect(recalled1?.metadata.ssg).toBe("jekyll");
324 |
325 | const recalled2 = await manager.recall("mem-002");
326 | expect(recalled2).not.toBeNull();
327 | expect(recalled2?.type).toBe("recommendation");
328 | });
329 |
330 | it("should skip malformed CSV rows (line 414)", async () => {
331 | // CSV with mismatched column counts
332 | const csvData = `id,timestamp,type,projectId,repository,ssg
333 | mem-001,2024-01-01T00:00:00.000Z,analysis,proj-001,github.com/test/repo,jekyll
334 | mem-002,2024-01-02T00:00:00.000Z,recommendation
335 | mem-003,2024-01-03T00:00:00.000Z,deployment,proj-003,github.com/test/repo3,mkdocs`;
336 |
337 | const imported = await manager.import(csvData, "csv");
338 |
339 | // Should import 2 (skipping the malformed row)
340 | expect(imported).toBe(2);
341 |
342 | // Verify valid entries were imported
343 | const recalled1 = await manager.recall("mem-001");
344 | expect(recalled1).not.toBeNull();
345 |
346 | // Malformed entry should not be imported
347 | const recalled2 = await manager.recall("mem-002");
348 | expect(recalled2).toBeNull();
349 |
350 | const recalled3 = await manager.recall("mem-003");
351 | expect(recalled3).not.toBeNull();
352 | });
353 |
354 | it("should import memories from JSON format", async () => {
355 | const jsonData = JSON.stringify([
356 | {
357 | id: "json-001",
358 | timestamp: "2024-01-01T00:00:00.000Z",
359 | type: "analysis",
360 | data: { test: "json-import" },
361 | metadata: { projectId: "json-proj" },
362 | },
363 | ]);
364 |
365 | const imported = await manager.import(jsonData, "json");
366 |
367 | expect(imported).toBe(1);
368 |
369 | const recalled = await manager.recall("json-001");
370 | expect(recalled).not.toBeNull();
371 | expect(recalled?.data.test).toBe("json-import");
372 | });
373 |
374 | it("should emit import-complete event (line 437)", async () => {
375 | let eventEmitted = false;
376 | let importedCount = 0;
377 |
378 | manager.on("import-complete", (count) => {
379 | eventEmitted = true;
380 | importedCount = count;
381 | });
382 |
383 | const jsonData = JSON.stringify([
384 | {
385 | id: "event-001",
386 | timestamp: "2024-01-01T00:00:00.000Z",
387 | type: "analysis",
388 | data: {},
389 | metadata: {},
390 | },
391 | ]);
392 |
393 | await manager.import(jsonData, "json");
394 |
395 | expect(eventEmitted).toBe(true);
396 | expect(importedCount).toBe(1);
397 | });
398 |
399 | it("should handle empty CSV import gracefully", async () => {
400 | const csvData = `id,timestamp,type,projectId,repository,ssg`;
401 |
402 | const imported = await manager.import(csvData, "csv");
403 |
404 | expect(imported).toBe(0);
405 | });
406 |
407 | it("should handle empty JSON import gracefully", async () => {
408 | const jsonData = JSON.stringify([]);
409 |
410 | const imported = await manager.import(jsonData, "json");
411 |
412 | expect(imported).toBe(0);
413 | });
414 | });
415 |
416 | describe("Export and Import Round-trip", () => {
417 | it("should maintain data integrity through CSV round-trip", async () => {
418 | // Create test data
419 | manager.setContext({
420 | projectId: "roundtrip-proj",
421 | repository: "github.com/test/roundtrip",
422 | });
423 | const originalEntry = await manager.remember(
424 | "analysis",
425 | { roundtrip: "test" },
426 | {
427 | ssg: "docusaurus",
428 | },
429 | );
430 |
431 | // Export as CSV
432 | const csvData = await manager.export("csv");
433 |
434 | // Create new manager and import
435 | const tempDir2 = path.join(
436 | os.tmpdir(),
437 | `manager-roundtrip-${Date.now()}`,
438 | );
439 | await fs.mkdir(tempDir2, { recursive: true });
440 | const manager2 = new MemoryManager(tempDir2);
441 | await manager2.initialize();
442 |
443 | const imported = await manager2.import(csvData, "csv");
444 | expect(imported).toBeGreaterThan(0);
445 |
446 | // Verify data matches
447 | const recalled = await manager2.recall(originalEntry.id);
448 | expect(recalled).not.toBeNull();
449 | expect(recalled?.type).toBe(originalEntry.type);
450 | expect(recalled?.metadata.projectId).toBe(
451 | originalEntry.metadata.projectId,
452 | );
453 | expect(recalled?.metadata.ssg).toBe(originalEntry.metadata.ssg);
454 |
455 | await manager2.close();
456 | await fs.rm(tempDir2, { recursive: true, force: true });
457 | });
458 |
459 | it("should maintain data integrity through JSON round-trip", async () => {
460 | // Create test data with complex structure
461 | manager.setContext({ projectId: "json-roundtrip" });
462 | const originalEntry = await manager.remember(
463 | "analysis",
464 | {
465 | complex: "data",
466 | nested: { value: 123 },
467 | array: [1, 2, 3],
468 | },
469 | {
470 | tags: ["tag1", "tag2"],
471 | },
472 | );
473 |
474 | // Export as JSON
475 | const jsonData = await manager.export("json");
476 |
477 | // Create new manager and import
478 | const tempDir2 = path.join(
479 | os.tmpdir(),
480 | `manager-json-roundtrip-${Date.now()}`,
481 | );
482 | await fs.mkdir(tempDir2, { recursive: true });
483 | const manager2 = new MemoryManager(tempDir2);
484 | await manager2.initialize();
485 |
486 | const imported = await manager2.import(jsonData, "json");
487 | expect(imported).toBeGreaterThan(0);
488 |
489 | // Verify complex data maintained
490 | const recalled = await manager2.recall(originalEntry.id);
491 | expect(recalled).not.toBeNull();
492 | expect(recalled?.data).toEqual(originalEntry.data);
493 | expect(recalled?.metadata.tags).toEqual(originalEntry.metadata.tags);
494 |
495 | await manager2.close();
496 | await fs.rm(tempDir2, { recursive: true, force: true });
497 | });
498 | });
499 | });
500 |
```
--------------------------------------------------------------------------------
/src/tools/evaluate-readme-health.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from "zod";
2 | import { promises as fs } from "fs";
3 | import path from "path";
4 | import { formatMCPResponse } from "../types/api.js";
5 |
6 | // Input validation schema
7 | const EvaluateReadmeHealthSchema = z.object({
8 | readme_path: z.string().min(1, "README path is required"),
9 | project_type: z
10 | .enum([
11 | "community_library",
12 | "enterprise_tool",
13 | "personal_project",
14 | "documentation",
15 | ])
16 | .optional()
17 | .default("community_library"),
18 | repository_path: z.string().optional(),
19 | });
20 |
21 | // Input type that matches what users actually pass (project_type is optional)
22 | export interface EvaluateReadmeHealthInput {
23 | readme_path: string;
24 | project_type?:
25 | | "community_library"
26 | | "enterprise_tool"
27 | | "personal_project"
28 | | "documentation";
29 | repository_path?: string;
30 | }
31 |
32 | // Health score interfaces
33 | interface HealthScoreComponent {
34 | name: string;
35 | score: number;
36 | maxScore: number;
37 | details: HealthCheckDetail[];
38 | }
39 |
40 | interface HealthCheckDetail {
41 | check: string;
42 | passed: boolean;
43 | points: number;
44 | maxPoints: number;
45 | recommendation?: string;
46 | }
47 |
48 | interface ReadmeHealthReport {
49 | overallScore: number;
50 | maxScore: number;
51 | grade: "A" | "B" | "C" | "D" | "F";
52 | components: {
53 | communityHealth: HealthScoreComponent;
54 | accessibility: HealthScoreComponent;
55 | onboarding: HealthScoreComponent;
56 | contentQuality: HealthScoreComponent;
57 | };
58 | recommendations: string[];
59 | strengths: string[];
60 | criticalIssues: string[];
61 | estimatedImprovementTime: string;
62 | }
63 |
64 | export async function evaluateReadmeHealth(input: EvaluateReadmeHealthInput) {
65 | const startTime = Date.now();
66 | try {
67 | // Validate input
68 | const validatedInput = EvaluateReadmeHealthSchema.parse(input);
69 |
70 | // Read README file
71 | const readmePath = path.resolve(validatedInput.readme_path);
72 | const readmeContent = await fs.readFile(readmePath, "utf-8");
73 |
74 | // Get repository context if available
75 | let repoContext: any = null;
76 | if (validatedInput.repository_path) {
77 | repoContext = await analyzeRepositoryContext(
78 | validatedInput.repository_path,
79 | );
80 | }
81 |
82 | // Evaluate all health components
83 | const communityHealth = evaluateCommunityHealth(readmeContent, repoContext);
84 | const accessibility = evaluateAccessibility(readmeContent);
85 | const onboarding = evaluateOnboarding(
86 | readmeContent,
87 | validatedInput.project_type,
88 | );
89 | const contentQuality = evaluateContentQuality(readmeContent);
90 |
91 | // Calculate overall score
92 | const totalScore =
93 | communityHealth.score +
94 | accessibility.score +
95 | onboarding.score +
96 | contentQuality.score;
97 | const maxTotalScore =
98 | communityHealth.maxScore +
99 | accessibility.maxScore +
100 | onboarding.maxScore +
101 | contentQuality.maxScore;
102 | const percentage = (totalScore / maxTotalScore) * 100;
103 |
104 | // Generate grade
105 | const grade = getGrade(percentage);
106 |
107 | // Generate recommendations and insights
108 | const recommendations = generateHealthRecommendations(
109 | [communityHealth, accessibility, onboarding, contentQuality],
110 | "general",
111 | );
112 | const strengths = identifyStrengths([
113 | communityHealth,
114 | accessibility,
115 | onboarding,
116 | contentQuality,
117 | ]);
118 | const criticalIssues = identifyCriticalIssues([
119 | communityHealth,
120 | accessibility,
121 | onboarding,
122 | contentQuality,
123 | ]);
124 |
125 | const report: ReadmeHealthReport = {
126 | overallScore: Math.round(percentage),
127 | maxScore: 100,
128 | grade,
129 | components: {
130 | communityHealth,
131 | accessibility,
132 | onboarding,
133 | contentQuality,
134 | },
135 | recommendations,
136 | strengths,
137 | criticalIssues,
138 | estimatedImprovementTime: estimateImprovementTime(
139 | recommendations.length,
140 | criticalIssues.length,
141 | ),
142 | };
143 |
144 | const response = {
145 | readmePath: validatedInput.readme_path,
146 | projectType: validatedInput.project_type,
147 | healthReport: report,
148 | summary: generateSummary(report),
149 | nextSteps: generateNextSteps(report),
150 | };
151 |
152 | return formatMCPResponse({
153 | success: true,
154 | data: response,
155 | metadata: {
156 | toolVersion: "1.0.0",
157 | executionTime: Date.now() - startTime,
158 | timestamp: new Date().toISOString(),
159 | },
160 | });
161 | } catch (error) {
162 | return formatMCPResponse({
163 | success: false,
164 | error: {
165 | code: "README_HEALTH_EVALUATION_FAILED",
166 | message: `Failed to evaluate README health: ${error}`,
167 | resolution: "Ensure README path is valid and file is readable",
168 | },
169 | metadata: {
170 | toolVersion: "1.0.0",
171 | executionTime: Date.now() - startTime,
172 | timestamp: new Date().toISOString(),
173 | },
174 | });
175 | }
176 | }
177 |
178 | function evaluateCommunityHealth(
179 | content: string,
180 | _repoContext: any,
181 | ): HealthScoreComponent {
182 | const checks: HealthCheckDetail[] = [
183 | {
184 | check: "Code of Conduct linked",
185 | passed: /code.of.conduct|conduct\.md|\.github\/code_of_conduct/i.test(
186 | content,
187 | ),
188 | points: 0,
189 | maxPoints: 5,
190 | recommendation:
191 | "Add a link to your Code of Conduct to establish community standards",
192 | },
193 | {
194 | check: "Contributing guidelines visible",
195 | passed: /contributing|contribute\.md|\.github\/contributing/i.test(
196 | content,
197 | ),
198 | points: 0,
199 | maxPoints: 5,
200 | recommendation:
201 | "Include contributing guidelines to help new contributors get started",
202 | },
203 | {
204 | check: "Issue/PR templates mentioned",
205 | passed:
206 | /issue.template|pull.request.template|\.github\/issue_template|\.github\/pull_request_template/i.test(
207 | content,
208 | ),
209 | points: 0,
210 | maxPoints: 5,
211 | recommendation:
212 | "Reference issue and PR templates to streamline contributions",
213 | },
214 | {
215 | check: "Security policy linked",
216 | passed: /security\.md|security.policy|\.github\/security/i.test(content),
217 | points: 0,
218 | maxPoints: 5,
219 | recommendation:
220 | "Add a security policy to handle vulnerability reports responsibly",
221 | },
222 | {
223 | check: "Support channels provided",
224 | passed: /support|help|discord|slack|discussions|forum|community/i.test(
225 | content,
226 | ),
227 | points: 0,
228 | maxPoints: 5,
229 | recommendation: "Provide clear support channels for users seeking help",
230 | },
231 | ];
232 |
233 | // Award points for passed checks
234 | checks.forEach((check) => {
235 | if (check.passed) {
236 | check.points = check.maxPoints;
237 | }
238 | });
239 |
240 | const totalScore = checks.reduce((sum, check) => sum + check.points, 0);
241 | const maxScore = checks.reduce((sum, check) => sum + check.maxPoints, 0);
242 |
243 | return {
244 | name: "Community Health",
245 | score: totalScore,
246 | maxScore,
247 | details: checks,
248 | };
249 | }
250 |
251 | function evaluateAccessibility(content: string): HealthScoreComponent {
252 | const lines = content.split("\n");
253 | const headings = lines.filter((line) => line.trim().startsWith("#"));
254 | const images = content.match(/!\[.*?\]\(.*?\)/g) || [];
255 |
256 | const checks: HealthCheckDetail[] = [
257 | {
258 | check: "Scannable structure with proper spacing",
259 | passed: content.includes("\n\n") && lines.length > 10,
260 | points: 0,
261 | maxPoints: 5,
262 | recommendation: "Use proper spacing and breaks to make content scannable",
263 | },
264 | {
265 | check: "Clear heading hierarchy",
266 | passed: headings.length >= 3 && headings.some((h) => h.startsWith("##")),
267 | points: 0,
268 | maxPoints: 5,
269 | recommendation:
270 | "Use proper heading hierarchy (H1, H2, H3) to structure content",
271 | },
272 | {
273 | check: "Alt text for images",
274 | passed:
275 | images.length === 0 || images.every((img) => !img.includes("),
276 | points: 0,
277 | maxPoints: 5,
278 | recommendation:
279 | "Add descriptive alt text for all images for screen readers",
280 | },
281 | {
282 | check: "Inclusive language",
283 | passed: !/\b(guys|blacklist|whitelist|master|slave)\b/i.test(content),
284 | points: 0,
285 | maxPoints: 5,
286 | recommendation:
287 | 'Use inclusive language (e.g., "team" instead of "guys", "allowlist/blocklist")',
288 | },
289 | ];
290 |
291 | // Award points for passed checks
292 | checks.forEach((check) => {
293 | if (check.passed) {
294 | check.points = check.maxPoints;
295 | }
296 | });
297 |
298 | const totalScore = checks.reduce((sum, check) => sum + check.points, 0);
299 | const maxScore = checks.reduce((sum, check) => sum + check.maxPoints, 0);
300 |
301 | return {
302 | name: "Accessibility",
303 | score: totalScore,
304 | maxScore,
305 | details: checks,
306 | };
307 | }
308 |
309 | function evaluateOnboarding(
310 | content: string,
311 | _projectType: string,
312 | ): HealthScoreComponent {
313 | const checks: HealthCheckDetail[] = [
314 | {
315 | check: "Quick start section",
316 | passed: /quick.start|getting.started|installation|setup/i.test(content),
317 | points: 0,
318 | maxPoints: 5,
319 | recommendation:
320 | "Add a quick start section to help users get up and running fast",
321 | },
322 | {
323 | check: "Prerequisites clearly listed",
324 | passed: /prerequisites|requirements|dependencies|before.you.begin/i.test(
325 | content,
326 | ),
327 | points: 0,
328 | maxPoints: 5,
329 | recommendation: "Clearly list all prerequisites and system requirements",
330 | },
331 | {
332 | check: "First contribution guide",
333 | passed: /first.contribution|new.contributor|beginner|newcomer/i.test(
334 | content,
335 | ),
336 | points: 0,
337 | maxPoints: 5,
338 | recommendation:
339 | "Include guidance specifically for first-time contributors",
340 | },
341 | {
342 | check: "Good first issues mentioned",
343 | passed: /good.first.issue|beginner.friendly|easy.pick|help.wanted/i.test(
344 | content,
345 | ),
346 | points: 0,
347 | maxPoints: 5,
348 | recommendation: "Mention good first issues or beginner-friendly tasks",
349 | },
350 | ];
351 |
352 | // Award points for passed checks
353 | checks.forEach((check) => {
354 | if (check.passed) {
355 | check.points = check.maxPoints;
356 | }
357 | });
358 |
359 | const totalScore = checks.reduce((sum, check) => sum + check.points, 0);
360 | const maxScore = checks.reduce((sum, check) => sum + check.maxPoints, 0);
361 |
362 | return {
363 | name: "Onboarding",
364 | score: totalScore,
365 | maxScore,
366 | details: checks,
367 | };
368 | }
369 |
370 | function evaluateContentQuality(content: string): HealthScoreComponent {
371 | const wordCount = content.split(/\s+/).length;
372 | const codeBlocks = (content.match(/```/g) || []).length / 2;
373 | const links = (content.match(/\[.*?\]\(.*?\)/g) || []).length;
374 |
375 | const checks: HealthCheckDetail[] = [
376 | {
377 | check: "Adequate content length",
378 | passed: wordCount >= 50 && wordCount <= 2000,
379 | points: 0,
380 | maxPoints: 5,
381 | recommendation:
382 | "Maintain optimal README length (50-2000 words) for readability",
383 | },
384 | {
385 | check: "Code examples provided",
386 | passed: codeBlocks >= 2,
387 | points: 0,
388 | maxPoints: 5,
389 | recommendation: "Include practical code examples to demonstrate usage",
390 | },
391 | {
392 | check: "External links present",
393 | passed: links >= 3,
394 | points: 0,
395 | maxPoints: 5,
396 | recommendation:
397 | "Add relevant external links (docs, demos, related projects)",
398 | },
399 | {
400 | check: "Project description clarity",
401 | passed: /## |### /.test(content) && content.length > 500,
402 | points: 0,
403 | maxPoints: 5,
404 | recommendation:
405 | "Provide clear, detailed project description with proper structure",
406 | },
407 | ];
408 |
409 | // Award points for passed checks
410 | checks.forEach((check) => {
411 | if (check.passed) {
412 | check.points = check.maxPoints;
413 | }
414 | });
415 |
416 | const totalScore = checks.reduce((sum, check) => sum + check.points, 0);
417 | const maxScore = checks.reduce((sum, check) => sum + check.maxPoints, 0);
418 |
419 | return {
420 | name: "Content Quality",
421 | score: totalScore,
422 | maxScore,
423 | details: checks,
424 | };
425 | }
426 |
427 | async function analyzeRepositoryContext(repoPath: string): Promise<any> {
428 | try {
429 | const repoDir = path.resolve(repoPath);
430 | const files = await fs.readdir(repoDir);
431 |
432 | return {
433 | hasCodeOfConduct: files.includes("CODE_OF_CONDUCT.md"),
434 | hasContributing: files.includes("CONTRIBUTING.md"),
435 | hasSecurityPolicy: files.includes("SECURITY.md"),
436 | hasGithubDir: files.includes(".github"),
437 | packageJson: files.includes("package.json"),
438 | };
439 | } catch (error) {
440 | return null;
441 | }
442 | }
443 |
444 | function getGrade(percentage: number): "A" | "B" | "C" | "D" | "F" {
445 | if (percentage >= 90) return "A";
446 | if (percentage >= 80) return "B";
447 | if (percentage >= 70) return "C";
448 | if (percentage >= 60) return "D";
449 | return "F";
450 | }
451 |
452 | function generateHealthRecommendations(
453 | analysis: any[],
454 | _projectType: string,
455 | ): string[] {
456 | const recommendations: string[] = [];
457 |
458 | analysis.forEach((component: any) => {
459 | component.details.forEach((detail: any) => {
460 | if (detail.points < detail.maxPoints) {
461 | recommendations.push(`${component.name}: ${detail.recommendation}`);
462 | }
463 | });
464 | });
465 |
466 | return recommendations.slice(0, 10); // Top 10 recommendations
467 | }
468 |
469 | function identifyStrengths(components: HealthScoreComponent[]): string[] {
470 | const strengths: string[] = [];
471 |
472 | components.forEach((component) => {
473 | const passedChecks = component.details.filter((detail) => detail.passed);
474 | if (passedChecks.length > component.details.length / 2) {
475 | strengths.push(
476 | `Strong ${component.name.toLowerCase()}: ${passedChecks
477 | .map((c) => c.check.toLowerCase())
478 | .join(", ")}`,
479 | );
480 | }
481 | });
482 |
483 | return strengths;
484 | }
485 |
486 | function identifyCriticalIssues(components: HealthScoreComponent[]): string[] {
487 | const critical: string[] = [];
488 |
489 | components.forEach((component) => {
490 | if (component.score < component.maxScore * 0.3) {
491 | // Less than 30% score
492 | critical.push(
493 | `Critical: Poor ${component.name.toLowerCase()} (${component.score}/${
494 | component.maxScore
495 | } points)`,
496 | );
497 | }
498 | });
499 |
500 | return critical;
501 | }
502 |
503 | function estimateImprovementTime(
504 | recommendationCount: number,
505 | criticalCount: number,
506 | ): string {
507 | const baseTime = recommendationCount * 15; // 15 minutes per recommendation
508 | const criticalTime = criticalCount * 30; // 30 minutes per critical issue
509 | const totalMinutes = baseTime + criticalTime;
510 |
511 | if (totalMinutes < 60) return `${totalMinutes} minutes`;
512 | if (totalMinutes < 480) return `${Math.round(totalMinutes / 60)} hours`;
513 | return `${Math.round(totalMinutes / 480)} days`;
514 | }
515 |
516 | function generateSummary(report: ReadmeHealthReport): string {
517 | const { overallScore, grade, components } = report;
518 |
519 | const componentScores = Object.values(components)
520 | .map((c) => `${c.name}: ${c.score}/${c.maxScore}`)
521 | .join(", ");
522 |
523 | return `README Health Score: ${overallScore}/100 (Grade ${grade}). Component breakdown: ${componentScores}. ${report.criticalIssues.length} critical issues identified.`;
524 | }
525 |
526 | function generateNextSteps(report: ReadmeHealthReport): string[] {
527 | const steps: string[] = [];
528 |
529 | if (report.criticalIssues.length > 0) {
530 | steps.push(
531 | "Address critical issues first to establish baseline community health",
532 | );
533 | }
534 |
535 | if (report.recommendations.length > 0) {
536 | steps.push(
537 | `Implement top ${Math.min(
538 | 3,
539 | report.recommendations.length,
540 | )} recommendations for quick wins`,
541 | );
542 | }
543 |
544 | if (report.overallScore < 85) {
545 | steps.push("Target 85+ health score for optimal community engagement");
546 | }
547 |
548 | steps.push("Re-evaluate after improvements to track progress");
549 |
550 | return steps;
551 | }
552 |
```
--------------------------------------------------------------------------------
/tests/tools/analyze-deployments.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for Phase 2.4: Deployment Analytics and Insights
3 | * Tests the analyze_deployments tool with comprehensive pattern analysis
4 | */
5 |
6 | import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
7 | import { promises as fs } from "fs";
8 | import { join } from "path";
9 | import { tmpdir } from "os";
10 | import {
11 | initializeKnowledgeGraph,
12 | getKnowledgeGraph,
13 | createOrUpdateProject,
14 | trackDeployment,
15 | } from "../../src/memory/kg-integration.js";
16 | import { analyzeDeployments } from "../../src/tools/analyze-deployments.js";
17 |
18 | describe("analyzeDeployments (Phase 2.4)", () => {
19 | let testDir: string;
20 | let originalEnv: string | undefined;
21 |
22 | beforeEach(async () => {
23 | // Create temporary test directory
24 | testDir = join(tmpdir(), `analyze-deployments-test-${Date.now()}`);
25 | await fs.mkdir(testDir, { recursive: true });
26 |
27 | // Set environment variable for storage
28 | originalEnv = process.env.DOCUMCP_STORAGE_DIR;
29 | process.env.DOCUMCP_STORAGE_DIR = testDir;
30 |
31 | // Initialize KG
32 | await initializeKnowledgeGraph(testDir);
33 | });
34 |
35 | afterEach(async () => {
36 | // Restore environment
37 | if (originalEnv) {
38 | process.env.DOCUMCP_STORAGE_DIR = originalEnv;
39 | } else {
40 | delete process.env.DOCUMCP_STORAGE_DIR;
41 | }
42 |
43 | // Clean up test directory
44 | try {
45 | await fs.rm(testDir, { recursive: true, force: true });
46 | } catch (error) {
47 | console.warn("Failed to clean up test directory:", error);
48 | }
49 | });
50 |
51 | /**
52 | * Helper function to create sample deployment data
53 | */
54 | const createSampleDeployments = async () => {
55 | const timestamp = new Date().toISOString();
56 |
57 | // Create 3 projects
58 | const project1 = await createOrUpdateProject({
59 | id: "project1",
60 | timestamp,
61 | path: "/test/project1",
62 | projectName: "Docusaurus Site",
63 | structure: {
64 | totalFiles: 50,
65 | languages: { typescript: 30, javascript: 20 },
66 | hasTests: true,
67 | hasCI: true,
68 | hasDocs: true,
69 | },
70 | });
71 |
72 | const project2 = await createOrUpdateProject({
73 | id: "project2",
74 | timestamp,
75 | path: "/test/project2",
76 | projectName: "Hugo Blog",
77 | structure: {
78 | totalFiles: 30,
79 | languages: { go: 15, html: 15 },
80 | hasTests: false,
81 | hasCI: true,
82 | hasDocs: true,
83 | },
84 | });
85 |
86 | const project3 = await createOrUpdateProject({
87 | id: "project3",
88 | timestamp,
89 | path: "/test/project3",
90 | projectName: "MkDocs Docs",
91 | structure: {
92 | totalFiles: 40,
93 | languages: { python: 25, markdown: 15 },
94 | hasTests: true,
95 | hasCI: true,
96 | hasDocs: true,
97 | },
98 | });
99 |
100 | // Track successful deployments
101 | await trackDeployment(project1.id, "docusaurus", true, {
102 | buildTime: 25000,
103 | });
104 | await trackDeployment(project1.id, "docusaurus", true, {
105 | buildTime: 23000,
106 | });
107 |
108 | await trackDeployment(project2.id, "hugo", true, { buildTime: 15000 });
109 | await trackDeployment(project2.id, "hugo", true, { buildTime: 14000 });
110 | await trackDeployment(project2.id, "hugo", true, { buildTime: 16000 });
111 |
112 | await trackDeployment(project3.id, "mkdocs", true, { buildTime: 30000 });
113 | await trackDeployment(project3.id, "mkdocs", false, {
114 | errorMessage: "Build failed",
115 | });
116 |
117 | return { project1, project2, project3 };
118 | };
119 |
120 | describe("Full Report Analysis", () => {
121 | it("should generate comprehensive analytics report with no data", async () => {
122 | const result = await analyzeDeployments({});
123 |
124 | const content = result.content[0];
125 | expect(content.type).toBe("text");
126 | const data = JSON.parse(content.text);
127 |
128 | expect(data.summary).toBeDefined();
129 | expect(data.summary.totalProjects).toBe(0);
130 | expect(data.summary.totalDeployments).toBe(0);
131 | expect(data.patterns).toEqual([]);
132 | // With 0 deployments, we get a warning insight about low success rate
133 | expect(Array.isArray(data.insights)).toBe(true);
134 | expect(data.recommendations).toBeDefined();
135 | });
136 |
137 | it("should generate comprehensive analytics report with sample data", async () => {
138 | await createSampleDeployments();
139 |
140 | const result = await analyzeDeployments({
141 | analysisType: "full_report",
142 | });
143 |
144 | const content = result.content[0];
145 | expect(content.type).toBe("text");
146 | const data = JSON.parse(content.text);
147 |
148 | // Verify summary
149 | expect(data.summary).toBeDefined();
150 | expect(data.summary.totalProjects).toBe(3);
151 | // Each project has 1 configuration node, so 3 total deployments tracked
152 | expect(data.summary.totalDeployments).toBeGreaterThanOrEqual(3);
153 | expect(data.summary.overallSuccessRate).toBeGreaterThan(0);
154 | expect(data.summary.mostUsedSSG).toBeDefined();
155 |
156 | // Verify patterns
157 | expect(data.patterns).toBeDefined();
158 | expect(data.patterns.length).toBeGreaterThan(0);
159 | expect(data.patterns[0]).toHaveProperty("ssg");
160 | expect(data.patterns[0]).toHaveProperty("totalDeployments");
161 | expect(data.patterns[0]).toHaveProperty("successRate");
162 |
163 | // Verify insights and recommendations
164 | expect(data.insights).toBeDefined();
165 | expect(data.recommendations).toBeDefined();
166 | });
167 |
168 | it("should include insights about high success rates", async () => {
169 | await createSampleDeployments();
170 |
171 | const result = await analyzeDeployments({
172 | analysisType: "full_report",
173 | });
174 |
175 | const content = result.content[0];
176 | const data = JSON.parse(content.text);
177 |
178 | // Should have success insights for docusaurus and hugo
179 | const successInsights = data.insights.filter(
180 | (i: any) => i.type === "success",
181 | );
182 | expect(successInsights.length).toBeGreaterThan(0);
183 | });
184 | });
185 |
186 | describe("SSG Statistics Analysis", () => {
187 | it("should return error for non-existent SSG", async () => {
188 | const result = await analyzeDeployments({
189 | analysisType: "ssg_stats",
190 | ssg: "nonexistent",
191 | });
192 |
193 | const content = result.content[0];
194 | const data = JSON.parse(content.text);
195 |
196 | // Should return error response when SSG has no data
197 | expect(data.success).toBe(false);
198 | expect(data.error).toBeDefined();
199 | });
200 |
201 | it("should return statistics for specific SSG", async () => {
202 | await createSampleDeployments();
203 |
204 | const result = await analyzeDeployments({
205 | analysisType: "ssg_stats",
206 | ssg: "docusaurus",
207 | });
208 |
209 | const content = result.content[0];
210 | expect(content.type).toBe("text");
211 | const data = JSON.parse(content.text);
212 |
213 | expect(data.ssg).toBe("docusaurus");
214 | // project1 has 2 deployments with docusaurus
215 | expect(data.totalDeployments).toBeGreaterThanOrEqual(1);
216 | expect(data.successfulDeployments).toBeGreaterThanOrEqual(1);
217 | expect(data.successRate).toBeGreaterThan(0);
218 | expect(data.averageBuildTime).toBeDefined();
219 | expect(data.projectCount).toBeGreaterThan(0);
220 | });
221 |
222 | it("should calculate average build time correctly", async () => {
223 | await createSampleDeployments();
224 |
225 | const result = await analyzeDeployments({
226 | analysisType: "ssg_stats",
227 | ssg: "hugo",
228 | });
229 |
230 | const content = result.content[0];
231 | const data = JSON.parse(content.text);
232 |
233 | expect(data.averageBuildTime).toBeDefined();
234 | // Hugo has 3 deployments with build times
235 | expect(data.averageBuildTime).toBeGreaterThan(0);
236 | expect(data.averageBuildTime).toBeLessThan(20000);
237 | });
238 |
239 | it("should show success rate less than 100% for failed deployments", async () => {
240 | await createSampleDeployments();
241 |
242 | const result = await analyzeDeployments({
243 | analysisType: "ssg_stats",
244 | ssg: "mkdocs",
245 | });
246 |
247 | const content = result.content[0];
248 | const data = JSON.parse(content.text);
249 |
250 | expect(data.totalDeployments).toBeGreaterThanOrEqual(1);
251 | expect(data.failedDeployments).toBeGreaterThanOrEqual(1);
252 | expect(data.successRate).toBeLessThan(1.0);
253 | });
254 | });
255 |
256 | describe("SSG Comparison Analysis", () => {
257 | it("should fail without enough SSGs", async () => {
258 | const result = await analyzeDeployments({
259 | analysisType: "compare",
260 | ssgs: ["docusaurus"],
261 | });
262 |
263 | const content = result.content[0];
264 | expect(content.type).toBe("text");
265 | const data = JSON.parse(content.text);
266 |
267 | // Should be an error response
268 | expect(data.success).toBe(false);
269 | expect(data.error).toBeDefined();
270 | expect(data.error.code).toBe("ANALYTICS_FAILED");
271 | });
272 |
273 | it("should compare multiple SSGs by success rate", async () => {
274 | await createSampleDeployments();
275 |
276 | const result = await analyzeDeployments({
277 | analysisType: "compare",
278 | ssgs: ["docusaurus", "hugo", "mkdocs"],
279 | });
280 |
281 | const content = result.content[0];
282 | expect(content.type).toBe("text");
283 | const data = JSON.parse(content.text);
284 |
285 | expect(Array.isArray(data)).toBe(true);
286 | expect(data.length).toBeGreaterThan(0);
287 |
288 | // Should be sorted by success rate (descending)
289 | for (let i = 0; i < data.length - 1; i++) {
290 | expect(data[i].pattern.successRate).toBeGreaterThanOrEqual(
291 | data[i + 1].pattern.successRate,
292 | );
293 | }
294 | });
295 |
296 | it("should include only SSGs with deployment data", async () => {
297 | await createSampleDeployments();
298 |
299 | const result = await analyzeDeployments({
300 | analysisType: "compare",
301 | ssgs: ["docusaurus", "nonexistent", "hugo"],
302 | });
303 |
304 | const content = result.content[0];
305 | const data = JSON.parse(content.text);
306 |
307 | // Should only include docusaurus and hugo
308 | expect(data.length).toBe(2);
309 | const ssgs = data.map((d: any) => d.ssg);
310 | expect(ssgs).toContain("docusaurus");
311 | expect(ssgs).toContain("hugo");
312 | expect(ssgs).not.toContain("nonexistent");
313 | });
314 | });
315 |
316 | describe("Health Score Analysis", () => {
317 | it("should calculate health score with no data", async () => {
318 | const result = await analyzeDeployments({
319 | analysisType: "health",
320 | });
321 |
322 | const content = result.content[0];
323 | expect(content.type).toBe("text");
324 | const data = JSON.parse(content.text);
325 |
326 | expect(data.score).toBeDefined();
327 | expect(data.score).toBeGreaterThanOrEqual(0);
328 | expect(data.score).toBeLessThanOrEqual(100);
329 | expect(data.factors).toBeDefined();
330 | expect(Array.isArray(data.factors)).toBe(true);
331 | expect(data.factors.length).toBe(4); // 4 factors
332 | });
333 |
334 | it("should calculate health score with sample data", async () => {
335 | await createSampleDeployments();
336 |
337 | const result = await analyzeDeployments({
338 | analysisType: "health",
339 | });
340 |
341 | const content = result.content[0];
342 | const data = JSON.parse(content.text);
343 |
344 | expect(data.score).toBeGreaterThan(0);
345 | expect(data.factors.length).toBe(4);
346 |
347 | // Check all factors present
348 | const factorNames = data.factors.map((f: any) => f.name);
349 | expect(factorNames).toContain("Overall Success Rate");
350 | expect(factorNames).toContain("Active Projects");
351 | expect(factorNames).toContain("Deployment Activity");
352 | expect(factorNames).toContain("SSG Diversity");
353 |
354 | // Each factor should have impact and status
355 | data.factors.forEach((factor: any) => {
356 | expect(factor.impact).toBeDefined();
357 | expect(factor.status).toMatch(/^(good|warning|critical)$/);
358 | });
359 | });
360 |
361 | it("should have good health with high success rate", async () => {
362 | await createSampleDeployments();
363 |
364 | const result = await analyzeDeployments({
365 | analysisType: "health",
366 | });
367 |
368 | const content = result.content[0];
369 | const data = JSON.parse(content.text);
370 |
371 | // Should have decent health with our sample data
372 | expect(data.score).toBeGreaterThan(30);
373 |
374 | const successRateFactor = data.factors.find(
375 | (f: any) => f.name === "Overall Success Rate",
376 | );
377 | expect(successRateFactor.status).toMatch(/^(good|warning)$/);
378 | });
379 | });
380 |
381 | describe("Trend Analysis", () => {
382 | it("should analyze trends with default period", async () => {
383 | await createSampleDeployments();
384 |
385 | const result = await analyzeDeployments({
386 | analysisType: "trends",
387 | });
388 |
389 | const content = result.content[0];
390 | expect(content.type).toBe("text");
391 | const data = JSON.parse(content.text);
392 |
393 | expect(Array.isArray(data)).toBe(true);
394 | // Trends are grouped by time periods
395 | });
396 |
397 | it("should analyze trends with custom period", async () => {
398 | await createSampleDeployments();
399 |
400 | const result = await analyzeDeployments({
401 | analysisType: "trends",
402 | periodDays: 7,
403 | });
404 |
405 | const content = result.content[0];
406 | const data = JSON.parse(content.text);
407 |
408 | expect(Array.isArray(data)).toBe(true);
409 | });
410 | });
411 |
412 | describe("Error Handling", () => {
413 | it("should handle missing SSG parameter for ssg_stats", async () => {
414 | const result = await analyzeDeployments({
415 | analysisType: "ssg_stats",
416 | });
417 |
418 | const content = result.content[0];
419 | const data = JSON.parse(content.text);
420 |
421 | expect(data.success).toBe(false);
422 | expect(data.error).toBeDefined();
423 | expect(data.error.code).toBe("ANALYTICS_FAILED");
424 | expect(data.error.message).toContain("SSG name required");
425 | });
426 |
427 | it("should handle invalid analysis type gracefully", async () => {
428 | const result = await analyzeDeployments({
429 | analysisType: "full_report",
430 | });
431 |
432 | const content = result.content[0];
433 | expect(content.type).toBe("text");
434 | // Should not throw, should return valid response
435 | });
436 | });
437 |
438 | describe("Recommendations Generation", () => {
439 | it("should generate recommendations based on patterns", async () => {
440 | await createSampleDeployments();
441 |
442 | const result = await analyzeDeployments({
443 | analysisType: "full_report",
444 | });
445 |
446 | const content = result.content[0];
447 | const data = JSON.parse(content.text);
448 |
449 | expect(data.recommendations).toBeDefined();
450 | expect(Array.isArray(data.recommendations)).toBe(true);
451 | expect(data.recommendations.length).toBeGreaterThan(0);
452 | });
453 |
454 | it("should recommend best performing SSG", async () => {
455 | await createSampleDeployments();
456 |
457 | const result = await analyzeDeployments({
458 | analysisType: "full_report",
459 | });
460 |
461 | const content = result.content[0];
462 | const data = JSON.parse(content.text);
463 |
464 | // Should have recommendations
465 | expect(data.recommendations.length).toBeGreaterThan(0);
466 | // At least one recommendation should mention an SSG or general advice
467 | const allText = data.recommendations.join(" ").toLowerCase();
468 | expect(allText.length).toBeGreaterThan(0);
469 | });
470 | });
471 |
472 | describe("Build Time Analysis", () => {
473 | it("should identify fast builds in insights", async () => {
474 | await createSampleDeployments();
475 |
476 | const result = await analyzeDeployments({
477 | analysisType: "full_report",
478 | });
479 |
480 | const content = result.content[0];
481 | const data = JSON.parse(content.text);
482 |
483 | // Hugo has ~15s builds, should be identified as fast
484 | const fastBuildInsights = data.insights.filter(
485 | (i: any) => i.title && i.title.includes("Fast Builds"),
486 | );
487 | expect(fastBuildInsights.length).toBeGreaterThan(0);
488 | });
489 | });
490 | });
491 |
```
--------------------------------------------------------------------------------
/tests/memory/kg-code-integration.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for Knowledge Graph Code Integration
3 | */
4 |
5 | import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
6 | import { promises as fs } from "fs";
7 | import path from "path";
8 | import { tmpdir } from "os";
9 | import {
10 | createCodeFileEntities,
11 | createDocumentationEntities,
12 | linkCodeToDocs,
13 | } from "../../src/memory/kg-code-integration.js";
14 | import { ExtractedContent } from "../../src/utils/content-extractor.js";
15 | import {
16 | initializeKnowledgeGraph,
17 | getKnowledgeGraph,
18 | } from "../../src/memory/kg-integration.js";
19 |
20 | describe("KG Code Integration", () => {
21 | let testDir: string;
22 | let projectId: string;
23 |
24 | beforeEach(async () => {
25 | // Create temporary directory for test files
26 | testDir = path.join(tmpdir(), `documcp-test-${Date.now()}`);
27 | await fs.mkdir(testDir, { recursive: true });
28 | projectId = `project:test_${Date.now()}`;
29 |
30 | // Initialize KG with test storage
31 | const storageDir = path.join(testDir, ".documcp/memory");
32 | await initializeKnowledgeGraph(storageDir);
33 | });
34 |
35 | afterEach(async () => {
36 | // Cleanup
37 | try {
38 | await fs.rm(testDir, { recursive: true, force: true });
39 | } catch {
40 | // Ignore cleanup errors
41 | }
42 | });
43 |
44 | describe("createCodeFileEntities", () => {
45 | it("should create code file entities from TypeScript files", async () => {
46 | // Create a test TypeScript file
47 | const srcDir = path.join(testDir, "src");
48 | await fs.mkdir(srcDir, { recursive: true });
49 |
50 | const tsContent = `
51 | export class UserService {
52 | async getUser(id: string) {
53 | return { id, name: "Test User" };
54 | }
55 |
56 | async createUser(data: any) {
57 | return { ...data, id: "123" };
58 | }
59 | }
60 |
61 | export async function validateUser(user: any) {
62 | return user.name && user.id;
63 | }
64 | `;
65 |
66 | await fs.writeFile(path.join(srcDir, "user.ts"), tsContent, "utf-8");
67 |
68 | // Create entities
69 | const entities = await createCodeFileEntities(projectId, testDir);
70 |
71 | // Assertions
72 | expect(entities.length).toBe(1);
73 | expect(entities[0].type).toBe("code_file");
74 | expect(entities[0].properties.language).toBe("typescript");
75 | expect(entities[0].properties.path).toBe("src/user.ts");
76 | expect(entities[0].properties.classes).toContain("UserService");
77 | expect(entities[0].properties.functions).toContain("validateUser");
78 | expect(entities[0].properties.contentHash).toBeDefined();
79 | expect(entities[0].properties.linesOfCode).toBeGreaterThan(0);
80 | });
81 |
82 | it("should create code file entities from Python files", async () => {
83 | const srcDir = path.join(testDir, "src");
84 | await fs.mkdir(srcDir, { recursive: true });
85 |
86 | const pyContent = `
87 | class Database:
88 | def connect(self):
89 | pass
90 |
91 | def query(self, sql):
92 | return []
93 |
94 | def initialize_db():
95 | return Database()
96 | `;
97 |
98 | await fs.writeFile(path.join(srcDir, "database.py"), pyContent, "utf-8");
99 |
100 | const entities = await createCodeFileEntities(projectId, testDir);
101 |
102 | expect(entities.length).toBe(1);
103 | expect(entities[0].properties.language).toBe("python");
104 | expect(entities[0].properties.classes).toContain("Database");
105 | expect(entities[0].properties.functions).toContain("initialize_db");
106 | });
107 |
108 | it("should handle nested directories", async () => {
109 | const nestedDir = path.join(testDir, "src", "services", "auth");
110 | await fs.mkdir(nestedDir, { recursive: true });
111 |
112 | await fs.writeFile(
113 | path.join(nestedDir, "login.ts"),
114 | "export function login() {}",
115 | "utf-8",
116 | );
117 |
118 | const entities = await createCodeFileEntities(projectId, testDir);
119 |
120 | expect(entities.length).toBe(1);
121 | expect(entities[0].properties.path).toBe("src/services/auth/login.ts");
122 | });
123 |
124 | it("should skip non-code files", async () => {
125 | const srcDir = path.join(testDir, "src");
126 | await fs.mkdir(srcDir, { recursive: true });
127 |
128 | await fs.writeFile(path.join(srcDir, "README.md"), "# Readme", "utf-8");
129 | await fs.writeFile(path.join(srcDir, "config.json"), "{}", "utf-8");
130 |
131 | const entities = await createCodeFileEntities(projectId, testDir);
132 |
133 | expect(entities.length).toBe(0);
134 | });
135 |
136 | it("should estimate complexity correctly", async () => {
137 | const srcDir = path.join(testDir, "src");
138 | await fs.mkdir(srcDir, { recursive: true });
139 |
140 | // Small file - low complexity
141 | const smallFile = "export function simple() { return 1; }";
142 | await fs.writeFile(path.join(srcDir, "small.ts"), smallFile, "utf-8");
143 |
144 | // Large file - high complexity
145 | const largeFile = Array(200)
146 | .fill("function test() { return 1; }")
147 | .join("\n");
148 | await fs.writeFile(path.join(srcDir, "large.ts"), largeFile, "utf-8");
149 |
150 | const entities = await createCodeFileEntities(projectId, testDir);
151 |
152 | const smallEntity = entities.find((e) =>
153 | e.properties.path.includes("small.ts"),
154 | );
155 | const largeEntity = entities.find((e) =>
156 | e.properties.path.includes("large.ts"),
157 | );
158 |
159 | expect(smallEntity?.properties.complexity).toBe("low");
160 | expect(largeEntity?.properties.complexity).toBe("high");
161 | });
162 |
163 | it("should create relationships with project", async () => {
164 | const srcDir = path.join(testDir, "src");
165 | await fs.mkdir(srcDir, { recursive: true });
166 | await fs.writeFile(
167 | path.join(srcDir, "test.ts"),
168 | "export function test() {}",
169 | "utf-8",
170 | );
171 |
172 | await createCodeFileEntities(projectId, testDir);
173 |
174 | const kg = await getKnowledgeGraph();
175 | const edges = await kg.findEdges({ source: projectId });
176 |
177 | expect(edges.some((e) => e.type === "depends_on")).toBe(true);
178 | });
179 | });
180 |
181 | describe("createDocumentationEntities", () => {
182 | it("should create documentation section entities from README", async () => {
183 | const extractedContent: ExtractedContent = {
184 | readme: {
185 | content: "# My Project\n\nThis is a test project.",
186 | sections: [
187 | {
188 | title: "My Project",
189 | content: "This is a test project.",
190 | level: 1,
191 | },
192 | { title: "Installation", content: "npm install", level: 2 },
193 | ],
194 | },
195 | existingDocs: [],
196 | adrs: [],
197 | codeExamples: [],
198 | apiDocs: [],
199 | };
200 |
201 | const entities = await createDocumentationEntities(
202 | projectId,
203 | extractedContent,
204 | );
205 |
206 | expect(entities.length).toBe(2);
207 | expect(entities[0].type).toBe("documentation_section");
208 | expect(entities[0].properties.sectionTitle).toBe("My Project");
209 | expect(entities[0].properties.contentHash).toBeDefined();
210 | expect(entities[0].properties.category).toBe("reference");
211 | });
212 |
213 | it("should categorize documentation correctly", async () => {
214 | const extractedContent: ExtractedContent = {
215 | existingDocs: [
216 | {
217 | path: "docs/tutorials/getting-started.md",
218 | title: "Getting Started",
219 | content: "# Tutorial",
220 | category: "tutorial",
221 | },
222 | {
223 | path: "docs/how-to/deploy.md",
224 | title: "Deploy Guide",
225 | content: "# How to Deploy",
226 | category: "how-to",
227 | },
228 | {
229 | path: "docs/api/reference.md",
230 | title: "API Reference",
231 | content: "# API",
232 | category: "reference",
233 | },
234 | ],
235 | adrs: [],
236 | codeExamples: [],
237 | apiDocs: [],
238 | };
239 |
240 | const entities = await createDocumentationEntities(
241 | projectId,
242 | extractedContent,
243 | );
244 |
245 | expect(entities.length).toBe(3);
246 | expect(
247 | entities.find((e) => e.properties.category === "tutorial"),
248 | ).toBeDefined();
249 | expect(
250 | entities.find((e) => e.properties.category === "how-to"),
251 | ).toBeDefined();
252 | expect(
253 | entities.find((e) => e.properties.category === "reference"),
254 | ).toBeDefined();
255 | });
256 |
257 | it("should extract code references from content", async () => {
258 | const extractedContent: ExtractedContent = {
259 | existingDocs: [
260 | {
261 | path: "docs/guide.md",
262 | title: "Guide",
263 | content:
264 | "Call `getUserById()` from `src/user.ts` using `UserService` class",
265 | category: "how-to",
266 | },
267 | ],
268 | adrs: [],
269 | codeExamples: [],
270 | apiDocs: [],
271 | };
272 |
273 | const entities = await createDocumentationEntities(
274 | projectId,
275 | extractedContent,
276 | );
277 |
278 | expect(entities[0].properties.referencedCodeFiles).toContain(
279 | "src/user.ts",
280 | );
281 | expect(entities[0].properties.referencedFunctions).toContain(
282 | "getUserById",
283 | );
284 | expect(entities[0].properties.referencedClasses).toContain("UserService");
285 | });
286 |
287 | it("should detect code examples in documentation", async () => {
288 | const extractedContent: ExtractedContent = {
289 | existingDocs: [
290 | {
291 | path: "docs/example.md",
292 | title: "Example",
293 | content: "# Example\n\n```typescript\nconst x = 1;\n```",
294 | },
295 | ],
296 | adrs: [],
297 | codeExamples: [],
298 | apiDocs: [],
299 | };
300 |
301 | const entities = await createDocumentationEntities(
302 | projectId,
303 | extractedContent,
304 | );
305 |
306 | expect(entities[0].properties.hasCodeExamples).toBe(true);
307 | expect(entities[0].properties.effectivenessScore).toBeGreaterThan(0.5);
308 | });
309 |
310 | it("should process ADRs as explanation category", async () => {
311 | const extractedContent: ExtractedContent = {
312 | existingDocs: [],
313 | adrs: [
314 | {
315 | number: "001",
316 | title: "Use TypeScript",
317 | status: "Accepted",
318 | content: "We will use TypeScript for type safety",
319 | decision: "Use TypeScript",
320 | consequences: "Better IDE support",
321 | },
322 | ],
323 | codeExamples: [],
324 | apiDocs: [],
325 | };
326 |
327 | const entities = await createDocumentationEntities(
328 | projectId,
329 | extractedContent,
330 | );
331 |
332 | expect(entities.length).toBe(1);
333 | expect(entities[0].properties.category).toBe("explanation");
334 | expect(entities[0].properties.sectionTitle).toBe("Use TypeScript");
335 | });
336 | });
337 |
338 | describe("linkCodeToDocs", () => {
339 | it("should create references edges when docs reference code", async () => {
340 | // Create code entity
341 | const srcDir = path.join(testDir, "src");
342 | await fs.mkdir(srcDir, { recursive: true });
343 | await fs.writeFile(
344 | path.join(srcDir, "user.ts"),
345 | "export function getUser() {}",
346 | "utf-8",
347 | );
348 |
349 | const codeFiles = await createCodeFileEntities(projectId, testDir);
350 |
351 | // Create doc entity that references the code
352 | const extractedContent: ExtractedContent = {
353 | existingDocs: [
354 | {
355 | path: "docs/api.md",
356 | title: "API",
357 | content: "Use `getUser()` from `src/user.ts`",
358 | category: "reference",
359 | },
360 | ],
361 | adrs: [],
362 | codeExamples: [],
363 | apiDocs: [],
364 | };
365 |
366 | const docSections = await createDocumentationEntities(
367 | projectId,
368 | extractedContent,
369 | );
370 |
371 | // Link them
372 | const edges = await linkCodeToDocs(codeFiles, docSections);
373 |
374 | // Should create references edge (doc -> code)
375 | const referencesEdge = edges.find((e) => e.type === "references");
376 | expect(referencesEdge).toBeDefined();
377 | expect(referencesEdge?.source).toBe(docSections[0].id);
378 | expect(referencesEdge?.target).toBe(codeFiles[0].id);
379 | expect(referencesEdge?.properties.referenceType).toBe("api-reference");
380 |
381 | // Should create documents edge (code -> doc)
382 | const documentsEdge = edges.find((e) => e.type === "documents");
383 | expect(documentsEdge).toBeDefined();
384 | expect(documentsEdge?.source).toBe(codeFiles[0].id);
385 | expect(documentsEdge?.target).toBe(docSections[0].id);
386 | });
387 |
388 | it("should detect outdated documentation", async () => {
389 | // Create code entity with recent modification
390 | const srcDir = path.join(testDir, "src");
391 | await fs.mkdir(srcDir, { recursive: true });
392 | await fs.writeFile(
393 | path.join(srcDir, "user.ts"),
394 | "export function getUser() {}",
395 | "utf-8",
396 | );
397 |
398 | const codeFiles = await createCodeFileEntities(projectId, testDir);
399 |
400 | // Simulate old documentation (modify lastUpdated)
401 | const extractedContent: ExtractedContent = {
402 | existingDocs: [
403 | {
404 | path: "docs/api.md",
405 | title: "API",
406 | content: "Use `getUser()` from `src/user.ts`",
407 | category: "reference",
408 | },
409 | ],
410 | adrs: [],
411 | codeExamples: [],
412 | apiDocs: [],
413 | };
414 |
415 | const docSections = await createDocumentationEntities(
416 | projectId,
417 | extractedContent,
418 | );
419 |
420 | // Manually set old timestamp on doc
421 | docSections[0].properties.lastUpdated = new Date(
422 | Date.now() - 86400000,
423 | ).toISOString();
424 |
425 | const edges = await linkCodeToDocs(codeFiles, docSections);
426 |
427 | // Should create outdated_for edge
428 | const outdatedEdge = edges.find((e) => e.type === "outdated_for");
429 | expect(outdatedEdge).toBeDefined();
430 | expect(outdatedEdge?.properties.severity).toBe("medium");
431 | });
432 |
433 | it("should determine coverage based on referenced functions", async () => {
434 | const srcDir = path.join(testDir, "src");
435 | await fs.mkdir(srcDir, { recursive: true });
436 |
437 | // Code with 3 functions
438 | await fs.writeFile(
439 | path.join(srcDir, "user.ts"),
440 | `
441 | export function getUser() {}
442 | export function createUser() {}
443 | export function deleteUser() {}
444 | `,
445 | "utf-8",
446 | );
447 |
448 | const codeFiles = await createCodeFileEntities(projectId, testDir);
449 |
450 | // Doc that only references 2 functions (66% coverage)
451 | const extractedContent: ExtractedContent = {
452 | existingDocs: [
453 | {
454 | path: "docs/api.md",
455 | title: "API",
456 | content: "Use `getUser()` and `createUser()` from `src/user.ts`",
457 | category: "reference",
458 | },
459 | ],
460 | adrs: [],
461 | codeExamples: [],
462 | apiDocs: [],
463 | };
464 |
465 | const docSections = await createDocumentationEntities(
466 | projectId,
467 | extractedContent,
468 | );
469 |
470 | const edges = await linkCodeToDocs(codeFiles, docSections);
471 |
472 | const documentsEdge = edges.find((e) => e.type === "documents");
473 | expect(documentsEdge?.properties.coverage).toBe("complete"); // >= 50%
474 | });
475 |
476 | it("should handle documentation with no code references", async () => {
477 | const srcDir = path.join(testDir, "src");
478 | await fs.mkdir(srcDir, { recursive: true });
479 | await fs.writeFile(
480 | path.join(srcDir, "user.ts"),
481 | "export function getUser() {}",
482 | "utf-8",
483 | );
484 |
485 | const codeFiles = await createCodeFileEntities(projectId, testDir);
486 |
487 | // Doc with no code references
488 | const extractedContent: ExtractedContent = {
489 | existingDocs: [
490 | {
491 | path: "docs/guide.md",
492 | title: "Guide",
493 | content: "This is a general guide with no code references",
494 | category: "tutorial",
495 | },
496 | ],
497 | adrs: [],
498 | codeExamples: [],
499 | apiDocs: [],
500 | };
501 |
502 | const docSections = await createDocumentationEntities(
503 | projectId,
504 | extractedContent,
505 | );
506 |
507 | const edges = await linkCodeToDocs(codeFiles, docSections);
508 |
509 | // Should not create edges between unrelated code and docs
510 | expect(edges.length).toBe(0);
511 | });
512 | });
513 | });
514 |
```
--------------------------------------------------------------------------------
/tests/tools/recommend-ssg-historical.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for Phase 2.1: Historical Deployment Data Integration
3 | * Tests the enhanced recommend_ssg tool with knowledge graph integration
4 | */
5 |
6 | import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
7 | import { promises as fs } from "fs";
8 | import { join } from "path";
9 | import { tmpdir } from "os";
10 | import {
11 | initializeKnowledgeGraph,
12 | createOrUpdateProject,
13 | trackDeployment,
14 | getMemoryManager,
15 | } from "../../src/memory/kg-integration.js";
16 | import { recommendSSG } from "../../src/tools/recommend-ssg.js";
17 | import { MemoryManager } from "../../src/memory/manager.js";
18 |
19 | describe("recommendSSG with Historical Data (Phase 2.1)", () => {
20 | let testDir: string;
21 | let originalEnv: string | undefined;
22 | let memoryManager: MemoryManager;
23 |
24 | beforeEach(async () => {
25 | // Create temporary test directory
26 | testDir = join(tmpdir(), `recommend-ssg-historical-test-${Date.now()}`);
27 | await fs.mkdir(testDir, { recursive: true });
28 |
29 | // Set environment variable for storage
30 | originalEnv = process.env.DOCUMCP_STORAGE_DIR;
31 | process.env.DOCUMCP_STORAGE_DIR = testDir;
32 |
33 | // Initialize KG and memory - this creates the global memory manager
34 | await initializeKnowledgeGraph(testDir);
35 |
36 | // Use the same memory manager instance that kg-integration created
37 | memoryManager = await getMemoryManager();
38 | });
39 |
40 | afterEach(async () => {
41 | // Restore environment
42 | if (originalEnv) {
43 | process.env.DOCUMCP_STORAGE_DIR = originalEnv;
44 | } else {
45 | delete process.env.DOCUMCP_STORAGE_DIR;
46 | }
47 |
48 | // Clean up test directory
49 | try {
50 | await fs.rm(testDir, { recursive: true, force: true });
51 | } catch (error) {
52 | console.warn("Failed to clean up test directory:", error);
53 | }
54 | });
55 |
56 | describe("Historical Data Retrieval", () => {
57 | it("should include historical data when similar projects exist", async () => {
58 | // Create a project with successful deployments
59 | const project1 = await createOrUpdateProject({
60 | id: "test_project_1",
61 | timestamp: new Date().toISOString(),
62 | path: "/test/project1",
63 | projectName: "Test Project 1",
64 | structure: {
65 | totalFiles: 50,
66 | languages: { typescript: 30, javascript: 20 },
67 | hasTests: true,
68 | hasCI: false,
69 | hasDocs: false,
70 | },
71 | });
72 |
73 | // Track successful Docusaurus deployments
74 | await trackDeployment(project1.id, "docusaurus", true, {
75 | buildTime: 45,
76 | });
77 | await trackDeployment(project1.id, "docusaurus", true, {
78 | buildTime: 42,
79 | });
80 |
81 | // Store analysis in memory for recommendation
82 | const memoryEntry = await memoryManager.remember("analysis", {
83 | path: "/test/project2",
84 | dependencies: {
85 | ecosystem: "javascript",
86 | languages: ["typescript", "javascript"],
87 | },
88 | structure: { totalFiles: 60 },
89 | });
90 |
91 | // Get recommendation
92 | const result = await recommendSSG({
93 | analysisId: memoryEntry.id,
94 | preferences: {},
95 | });
96 |
97 | const content = result.content[0];
98 | expect(content.type).toBe("text");
99 | const data = JSON.parse(content.text);
100 |
101 | // Should include historical data
102 | expect(data.historicalData).toBeDefined();
103 | expect(data.historicalData.similarProjectCount).toBeGreaterThan(0);
104 | expect(data.historicalData.successRates.docusaurus).toBeDefined();
105 | expect(data.historicalData.successRates.docusaurus.rate).toBe(1.0);
106 | expect(data.historicalData.successRates.docusaurus.sampleSize).toBe(2);
107 | });
108 |
109 | it("should boost confidence when historical success rate is high", async () => {
110 | // Create multiple successful projects
111 | for (let i = 0; i < 3; i++) {
112 | const project = await createOrUpdateProject({
113 | id: `project_${i}`,
114 | timestamp: new Date().toISOString(),
115 | path: `/test/project${i}`,
116 | projectName: `Project ${i}`,
117 | structure: {
118 | totalFiles: 50,
119 | languages: { typescript: 50 },
120 | hasTests: true,
121 | hasCI: false,
122 | hasDocs: false,
123 | },
124 | });
125 |
126 | // Track successful Hugo deployments
127 | await trackDeployment(project.id, "hugo", true, { buildTime: 30 });
128 | }
129 |
130 | // Store analysis
131 | const memoryEntry = await memoryManager.remember("analysis", {
132 | path: "/test/new-project",
133 | dependencies: {
134 | ecosystem: "go",
135 | languages: ["typescript"],
136 | },
137 | structure: { totalFiles: 60 },
138 | });
139 |
140 | const result = await recommendSSG({ analysisId: memoryEntry.id });
141 | const content = result.content[0];
142 | const data = JSON.parse(content.text);
143 |
144 | // Should have high confidence due to historical success
145 | expect(data.confidence).toBeGreaterThan(0.9);
146 | expect(data.reasoning[0]).toContain("100% success rate");
147 | });
148 |
149 | it("should reduce confidence when historical success rate is low", async () => {
150 | // Create project with failed deployments
151 | const project = await createOrUpdateProject({
152 | id: "failing_project",
153 | timestamp: new Date().toISOString(),
154 | path: "/test/failing",
155 | projectName: "Failing Project",
156 | structure: {
157 | totalFiles: 50,
158 | languages: { python: 50 },
159 | hasTests: true,
160 | hasCI: false,
161 | hasDocs: false,
162 | },
163 | });
164 |
165 | // Track mostly failed Jekyll deployments
166 | await trackDeployment(project.id, "jekyll", false, {
167 | errorMessage: "Build failed",
168 | });
169 | await trackDeployment(project.id, "jekyll", false, {
170 | errorMessage: "Build failed",
171 | });
172 | await trackDeployment(project.id, "jekyll", true, { buildTime: 60 });
173 |
174 | // Store analysis
175 | const memoryEntry003 = await memoryManager.remember("analysis", {
176 | path: "/test/new-python",
177 | dependencies: {
178 | ecosystem: "python",
179 | languages: ["python"],
180 | },
181 | structure: { totalFiles: 60 },
182 | });
183 |
184 | const result = await recommendSSG({ analysisId: memoryEntry003.id });
185 | const content = result.content[0];
186 | const data = JSON.parse(content.text);
187 |
188 | // Should have reduced confidence
189 | expect(data.confidence).toBeLessThan(0.8);
190 | expect(data.reasoning[0]).toContain("33% success rate");
191 | });
192 |
193 | it("should switch to top performer when significantly better", async () => {
194 | // Create projects with mixed results
195 | const project1 = await createOrUpdateProject({
196 | id: "project_mixed_1",
197 | timestamp: new Date().toISOString(),
198 | path: "/test/mixed1",
199 | projectName: "Mixed Project 1",
200 | structure: {
201 | totalFiles: 50,
202 | languages: { javascript: 50 },
203 | hasTests: true,
204 | hasCI: false,
205 | hasDocs: false,
206 | },
207 | });
208 |
209 | // Docusaurus: 50% success rate (2 samples)
210 | await trackDeployment(project1.id, "docusaurus", true);
211 | await trackDeployment(project1.id, "docusaurus", false);
212 |
213 | // Eleventy: 100% success rate (3 samples)
214 | const project2 = await createOrUpdateProject({
215 | id: "project_mixed_2",
216 | timestamp: new Date().toISOString(),
217 | path: "/test/mixed2",
218 | projectName: "Mixed Project 2",
219 | structure: {
220 | totalFiles: 50,
221 | languages: { javascript: 50 },
222 | hasTests: true,
223 | hasCI: false,
224 | hasDocs: false,
225 | },
226 | });
227 |
228 | await trackDeployment(project2.id, "eleventy", true);
229 | await trackDeployment(project2.id, "eleventy", true);
230 | await trackDeployment(project2.id, "eleventy", true);
231 |
232 | // Store analysis preferring JavaScript
233 | const memoryEntry004 = await memoryManager.remember("analysis", {
234 | path: "/test/new-js",
235 | dependencies: {
236 | ecosystem: "javascript",
237 | languages: ["javascript"],
238 | },
239 | structure: { totalFiles: 40 },
240 | });
241 |
242 | const result = await recommendSSG({ analysisId: memoryEntry004.id });
243 | const content = result.content[0];
244 | const data = JSON.parse(content.text);
245 |
246 | // Should switch to Eleventy due to better success rate
247 | expect(data.recommended).toBe("eleventy");
248 | expect(data.reasoning[0]).toContain("Switching to eleventy");
249 | expect(data.reasoning[0]).toContain("100% success rate");
250 | });
251 |
252 | it("should mention top performer as alternative if not switching", async () => {
253 | // Create successful Hugo deployments
254 | const project = await createOrUpdateProject({
255 | id: "hugo_success",
256 | timestamp: new Date().toISOString(),
257 | path: "/test/hugo",
258 | projectName: "Hugo Success",
259 | structure: {
260 | totalFiles: 100,
261 | languages: { go: 80, markdown: 20 },
262 | hasTests: true,
263 | hasCI: false,
264 | hasDocs: false,
265 | },
266 | });
267 |
268 | await trackDeployment(project.id, "hugo", true);
269 | await trackDeployment(project.id, "hugo", true);
270 |
271 | // Store analysis for different ecosystem
272 | const memoryEntry005 = await memoryManager.remember("analysis", {
273 | path: "/test/new-python",
274 | dependencies: {
275 | ecosystem: "python",
276 | languages: ["python"],
277 | },
278 | structure: { totalFiles: 60 },
279 | });
280 |
281 | const result = await recommendSSG({ analysisId: memoryEntry005.id });
282 | const content = result.content[0];
283 | const data = JSON.parse(content.text);
284 |
285 | // Should keep Python recommendation but mention Hugo
286 | expect(data.recommended).toBe("mkdocs");
287 | const hugoMention = data.reasoning.find((r: string) =>
288 | r.includes("hugo"),
289 | );
290 | expect(hugoMention).toBeDefined();
291 | });
292 |
293 | it("should include deployment statistics in reasoning", async () => {
294 | // Create multiple projects with various deployments
295 | for (let i = 0; i < 3; i++) {
296 | const project = await createOrUpdateProject({
297 | id: `stats_project_${i}`,
298 | timestamp: new Date().toISOString(),
299 | path: `/test/stats${i}`,
300 | projectName: `Stats Project ${i}`,
301 | structure: {
302 | totalFiles: 50,
303 | languages: { typescript: 50 },
304 | hasTests: true,
305 | hasCI: false,
306 | hasDocs: false,
307 | },
308 | });
309 |
310 | await trackDeployment(project.id, "docusaurus", true);
311 | await trackDeployment(project.id, "docusaurus", true);
312 | }
313 |
314 | const memoryEntry006 = await memoryManager.remember("analysis", {
315 | path: "/test/stats-new",
316 | dependencies: {
317 | ecosystem: "javascript",
318 | languages: ["typescript"],
319 | },
320 | structure: { totalFiles: 50 },
321 | });
322 |
323 | const result = await recommendSSG({ analysisId: memoryEntry006.id });
324 | const content = result.content[0];
325 | const data = JSON.parse(content.text);
326 |
327 | // Should mention deployment statistics
328 | const statsReasoning = data.reasoning.find((r: string) =>
329 | r.includes("deployment(s) across"),
330 | );
331 | expect(statsReasoning).toBeDefined();
332 | expect(statsReasoning).toContain("6 deployment(s)");
333 | expect(statsReasoning).toContain("3 similar project(s)");
334 | });
335 | });
336 |
337 | describe("Historical Data Structure", () => {
338 | it("should provide complete historical data structure", async () => {
339 | const project = await createOrUpdateProject({
340 | id: "structure_test",
341 | timestamp: new Date().toISOString(),
342 | path: "/test/structure",
343 | projectName: "Structure Test",
344 | structure: {
345 | totalFiles: 50,
346 | languages: { javascript: 50 },
347 | hasTests: true,
348 | hasCI: false,
349 | hasDocs: false,
350 | },
351 | });
352 |
353 | await trackDeployment(project.id, "jekyll", true);
354 | await trackDeployment(project.id, "hugo", true);
355 | await trackDeployment(project.id, "hugo", true);
356 |
357 | const memoryEntry007 = await memoryManager.remember("analysis", {
358 | path: "/test/structure-new",
359 | dependencies: {
360 | ecosystem: "javascript",
361 | languages: ["javascript"],
362 | },
363 | structure: { totalFiles: 50 },
364 | });
365 |
366 | const result = await recommendSSG({ analysisId: memoryEntry007.id });
367 | const content = result.content[0];
368 | const data = JSON.parse(content.text);
369 |
370 | expect(data.historicalData).toBeDefined();
371 | expect(data.historicalData.similarProjectCount).toBe(1);
372 | expect(data.historicalData.successRates).toBeDefined();
373 | expect(data.historicalData.successRates.jekyll).toEqual({
374 | rate: 1.0,
375 | sampleSize: 1,
376 | });
377 | expect(data.historicalData.successRates.hugo).toEqual({
378 | rate: 1.0,
379 | sampleSize: 2,
380 | });
381 | expect(data.historicalData.topPerformer).toBeDefined();
382 | expect(data.historicalData.topPerformer?.ssg).toBe("hugo");
383 | expect(data.historicalData.topPerformer?.deploymentCount).toBe(2);
384 | });
385 |
386 | it("should handle no historical data gracefully", async () => {
387 | const memoryEntry008 = await memoryManager.remember("analysis", {
388 | path: "/test/no-history",
389 | dependencies: {
390 | ecosystem: "ruby",
391 | languages: ["ruby"],
392 | },
393 | structure: { totalFiles: 30 },
394 | });
395 |
396 | const result = await recommendSSG({ analysisId: memoryEntry008.id });
397 | const content = result.content[0];
398 | const data = JSON.parse(content.text);
399 |
400 | // Should still make recommendation
401 | expect(data.recommended).toBe("jekyll");
402 | expect(data.confidence).toBeGreaterThan(0);
403 |
404 | // Historical data should show no similar projects
405 | expect(data.historicalData).toBeDefined();
406 | expect(data.historicalData.similarProjectCount).toBe(0);
407 | expect(Object.keys(data.historicalData.successRates)).toHaveLength(0);
408 | });
409 | });
410 |
411 | describe("Edge Cases", () => {
412 | it("should handle single deployment samples cautiously", async () => {
413 | const project = await createOrUpdateProject({
414 | id: "single_sample",
415 | timestamp: new Date().toISOString(),
416 | path: "/test/single",
417 | projectName: "Single Sample",
418 | structure: {
419 | totalFiles: 50,
420 | languages: { python: 50 },
421 | hasTests: true,
422 | hasCI: false,
423 | hasDocs: false,
424 | },
425 | });
426 |
427 | // Single successful deployment
428 | await trackDeployment(project.id, "mkdocs", true);
429 |
430 | const memoryEntry009 = await memoryManager.remember("analysis", {
431 | path: "/test/single-new",
432 | dependencies: {
433 | ecosystem: "python",
434 | languages: ["python"],
435 | },
436 | structure: { totalFiles: 50 },
437 | });
438 |
439 | const result = await recommendSSG({ analysisId: memoryEntry009.id });
440 | const content = result.content[0];
441 | const data = JSON.parse(content.text);
442 |
443 | // Should not be a top performer with only 1 sample
444 | expect(data.historicalData?.topPerformer).toBeUndefined();
445 | });
446 |
447 | it("should handle knowledge graph initialization failure", async () => {
448 | // Use invalid storage directory
449 | const invalidDir = "/invalid/path/that/does/not/exist";
450 | const memoryEntry010 = await memoryManager.remember("analysis", {
451 | path: "/test/kg-fail",
452 | dependencies: {
453 | ecosystem: "javascript",
454 | languages: ["javascript"],
455 | },
456 | structure: { totalFiles: 50 },
457 | });
458 |
459 | // Should still make recommendation despite KG failure
460 | const result = await recommendSSG({ analysisId: memoryEntry010.id });
461 | const content = result.content[0];
462 | const data = JSON.parse(content.text);
463 |
464 | expect(data.recommended).toBeDefined();
465 | expect(data.confidence).toBeGreaterThan(0);
466 | });
467 | });
468 | });
469 |
```
--------------------------------------------------------------------------------
/src/memory/deployment-analytics.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Deployment Analytics Module
3 | * Phase 2.4: Pattern Analysis and Insights
4 | *
5 | * Analyzes deployment history to identify patterns, trends, and provide insights
6 | */
7 |
8 | import { getKnowledgeGraph } from "./kg-integration.js";
9 | import { GraphNode, GraphEdge } from "./knowledge-graph.js";
10 |
11 | export interface DeploymentPattern {
12 | ssg: string;
13 | totalDeployments: number;
14 | successfulDeployments: number;
15 | failedDeployments: number;
16 | successRate: number;
17 | averageBuildTime?: number;
18 | commonTechnologies: string[];
19 | projectCount: number;
20 | }
21 |
22 | export interface DeploymentTrend {
23 | period: string;
24 | deployments: number;
25 | successRate: number;
26 | topSSG: string;
27 | }
28 |
29 | export interface DeploymentInsight {
30 | type: "success" | "warning" | "recommendation";
31 | title: string;
32 | description: string;
33 | ssg?: string;
34 | metric?: number;
35 | }
36 |
37 | export interface AnalyticsReport {
38 | summary: {
39 | totalProjects: number;
40 | totalDeployments: number;
41 | overallSuccessRate: number;
42 | mostUsedSSG: string;
43 | mostSuccessfulSSG: string;
44 | };
45 | patterns: DeploymentPattern[];
46 | insights: DeploymentInsight[];
47 | recommendations: string[];
48 | }
49 |
50 | /**
51 | * Deployment Analytics Engine
52 | */
53 | export class DeploymentAnalytics {
54 | /**
55 | * Generate comprehensive analytics report
56 | */
57 | async generateReport(): Promise<AnalyticsReport> {
58 | const kg = await getKnowledgeGraph();
59 |
60 | // Get all projects and deployments
61 | const projects = await kg.findNodes({ type: "project" });
62 | const deploymentEdges = await kg.findEdges({
63 | properties: { baseType: "project_deployed_with" },
64 | });
65 |
66 | // Aggregate deployment data by SSG
67 | const ssgStats = await this.aggregateSSGStatistics(
68 | projects,
69 | deploymentEdges,
70 | );
71 |
72 | // Calculate summary metrics
73 | const summary = this.calculateSummary(ssgStats, projects.length);
74 |
75 | // Identify patterns
76 | const patterns = this.identifyPatterns(ssgStats);
77 |
78 | // Generate insights
79 | const insights = this.generateInsights(patterns, summary);
80 |
81 | // Generate recommendations
82 | const recommendations = this.generateRecommendations(patterns, insights);
83 |
84 | return {
85 | summary,
86 | patterns,
87 | insights,
88 | recommendations,
89 | };
90 | }
91 |
92 | /**
93 | * Get deployment statistics for a specific SSG
94 | */
95 | async getSSGStatistics(ssg: string): Promise<DeploymentPattern | null> {
96 | const kg = await getKnowledgeGraph();
97 |
98 | const deployments = await kg.findEdges({
99 | properties: { baseType: "project_deployed_with" },
100 | });
101 |
102 | const allNodes = await kg.getAllNodes();
103 |
104 | // Filter deployments for this SSG
105 | const ssgDeployments = deployments.filter((edge) => {
106 | const configNode = allNodes.find((n) => n.id === edge.target);
107 | return configNode?.properties.ssg === ssg;
108 | });
109 |
110 | if (ssgDeployments.length === 0) {
111 | return null;
112 | }
113 |
114 | const successful = ssgDeployments.filter(
115 | (d) => d.properties.success,
116 | ).length;
117 | const failed = ssgDeployments.length - successful;
118 |
119 | // Calculate average build time
120 | const buildTimes = ssgDeployments
121 | .filter((d) => d.properties.buildTime)
122 | .map((d) => d.properties.buildTime as number);
123 |
124 | const averageBuildTime =
125 | buildTimes.length > 0
126 | ? buildTimes.reduce((a, b) => a + b, 0) / buildTimes.length
127 | : undefined;
128 |
129 | // Get unique projects using this SSG
130 | const projectIds = new Set(ssgDeployments.map((d) => d.source));
131 |
132 | // Get common technologies from projects
133 | const technologies = new Set<string>();
134 | for (const projectId of projectIds) {
135 | const project = allNodes.find((n) => n.id === projectId);
136 | if (project?.properties.technologies) {
137 | project.properties.technologies.forEach((tech: string) =>
138 | technologies.add(tech),
139 | );
140 | }
141 | }
142 |
143 | return {
144 | ssg,
145 | totalDeployments: ssgDeployments.length,
146 | successfulDeployments: successful,
147 | failedDeployments: failed,
148 | successRate: successful / ssgDeployments.length,
149 | averageBuildTime,
150 | commonTechnologies: Array.from(technologies),
151 | projectCount: projectIds.size,
152 | };
153 | }
154 |
155 | /**
156 | * Compare multiple SSGs
157 | */
158 | async compareSSGs(
159 | ssgs: string[],
160 | ): Promise<{ ssg: string; pattern: DeploymentPattern }[]> {
161 | const comparisons: { ssg: string; pattern: DeploymentPattern }[] = [];
162 |
163 | for (const ssg of ssgs) {
164 | const pattern = await this.getSSGStatistics(ssg);
165 | if (pattern) {
166 | comparisons.push({ ssg, pattern });
167 | }
168 | }
169 |
170 | // Sort by success rate
171 | return comparisons.sort(
172 | (a, b) => b.pattern.successRate - a.pattern.successRate,
173 | );
174 | }
175 |
176 | /**
177 | * Identify deployment trends over time
178 | */
179 | async identifyTrends(periodDays: number = 30): Promise<DeploymentTrend[]> {
180 | const kg = await getKnowledgeGraph();
181 | const deployments = await kg.findEdges({
182 | properties: { baseType: "project_deployed_with" },
183 | });
184 |
185 | // Group deployments by time period
186 | const now = Date.now();
187 | const periodMs = periodDays * 24 * 60 * 60 * 1000;
188 |
189 | const trends: Map<string, DeploymentTrend> = new Map();
190 |
191 | for (const deployment of deployments) {
192 | const timestamp = deployment.properties.timestamp;
193 | if (!timestamp) continue;
194 |
195 | const deploymentTime = new Date(timestamp).getTime();
196 | const periodsAgo = Math.floor((now - deploymentTime) / periodMs);
197 |
198 | if (periodsAgo < 0 || periodsAgo > 12) continue; // Last 12 periods
199 |
200 | const periodKey = `${periodsAgo} periods ago`;
201 |
202 | if (!trends.has(periodKey)) {
203 | trends.set(periodKey, {
204 | period: periodKey,
205 | deployments: 0,
206 | successRate: 0,
207 | topSSG: "",
208 | });
209 | }
210 |
211 | const trend = trends.get(periodKey)!;
212 | trend.deployments++;
213 |
214 | if (deployment.properties.success) {
215 | trend.successRate++;
216 | }
217 | }
218 |
219 | // Calculate success rates and identify top SSG per period
220 | for (const trend of trends.values()) {
221 | trend.successRate = trend.successRate / trend.deployments;
222 | }
223 |
224 | return Array.from(trends.values()).sort((a, b) =>
225 | a.period.localeCompare(b.period),
226 | );
227 | }
228 |
229 | /**
230 | * Get deployment health score (0-100)
231 | */
232 | async getHealthScore(): Promise<{
233 | score: number;
234 | factors: {
235 | name: string;
236 | impact: number;
237 | status: "good" | "warning" | "critical";
238 | }[];
239 | }> {
240 | const report = await this.generateReport();
241 |
242 | const factors: {
243 | name: string;
244 | impact: number;
245 | status: "good" | "warning" | "critical";
246 | }[] = [];
247 | let totalScore = 0;
248 |
249 | // Factor 1: Overall success rate (40 points)
250 | const successRateScore = report.summary.overallSuccessRate * 40;
251 | totalScore += successRateScore;
252 | factors.push({
253 | name: "Overall Success Rate",
254 | impact: successRateScore,
255 | status:
256 | report.summary.overallSuccessRate > 0.8
257 | ? "good"
258 | : report.summary.overallSuccessRate > 0.5
259 | ? "warning"
260 | : "critical",
261 | });
262 |
263 | // Factor 2: Number of projects (20 points)
264 | const projectScore = Math.min(20, report.summary.totalProjects * 2);
265 | totalScore += projectScore;
266 | factors.push({
267 | name: "Active Projects",
268 | impact: projectScore,
269 | status:
270 | report.summary.totalProjects > 5
271 | ? "good"
272 | : report.summary.totalProjects > 2
273 | ? "warning"
274 | : "critical",
275 | });
276 |
277 | // Factor 3: Deployment frequency (20 points)
278 | const deploymentScore = Math.min(20, report.summary.totalDeployments * 1.5);
279 | totalScore += deploymentScore;
280 | factors.push({
281 | name: "Deployment Activity",
282 | impact: deploymentScore,
283 | status:
284 | report.summary.totalDeployments > 10
285 | ? "good"
286 | : report.summary.totalDeployments > 5
287 | ? "warning"
288 | : "critical",
289 | });
290 |
291 | // Factor 4: SSG diversity (20 points)
292 | const ssgDiversity = report.patterns.length;
293 | const diversityScore = Math.min(20, ssgDiversity * 5);
294 | totalScore += diversityScore;
295 | factors.push({
296 | name: "SSG Diversity",
297 | impact: diversityScore,
298 | status:
299 | ssgDiversity > 3 ? "good" : ssgDiversity > 1 ? "warning" : "critical",
300 | });
301 |
302 | return {
303 | score: Math.round(totalScore),
304 | factors,
305 | };
306 | }
307 |
308 | /**
309 | * Private: Aggregate SSG statistics
310 | */
311 | private async aggregateSSGStatistics(
312 | projects: GraphNode[],
313 | deploymentEdges: GraphEdge[],
314 | ): Promise<Map<string, DeploymentPattern>> {
315 | const kg = await getKnowledgeGraph();
316 | const allNodes = await kg.getAllNodes();
317 | const ssgStats = new Map<string, DeploymentPattern>();
318 |
319 | for (const deployment of deploymentEdges) {
320 | const configNode = allNodes.find((n) => n.id === deployment.target);
321 | if (!configNode || configNode.type !== "configuration") continue;
322 |
323 | const ssg = configNode.properties.ssg;
324 | if (!ssg) continue;
325 |
326 | if (!ssgStats.has(ssg)) {
327 | ssgStats.set(ssg, {
328 | ssg,
329 | totalDeployments: 0,
330 | successfulDeployments: 0,
331 | failedDeployments: 0,
332 | successRate: 0,
333 | commonTechnologies: [],
334 | projectCount: 0,
335 | });
336 | }
337 |
338 | const stats = ssgStats.get(ssg)!;
339 | stats.totalDeployments++;
340 |
341 | if (deployment.properties.success) {
342 | stats.successfulDeployments++;
343 | } else {
344 | stats.failedDeployments++;
345 | }
346 |
347 | // Track build times
348 | if (deployment.properties.buildTime) {
349 | if (!stats.averageBuildTime) {
350 | stats.averageBuildTime = 0;
351 | }
352 | stats.averageBuildTime += deployment.properties.buildTime;
353 | }
354 | }
355 |
356 | // Calculate final metrics
357 | for (const stats of ssgStats.values()) {
358 | stats.successRate = stats.successfulDeployments / stats.totalDeployments;
359 | if (stats.averageBuildTime) {
360 | stats.averageBuildTime /= stats.totalDeployments;
361 | }
362 | }
363 |
364 | return ssgStats;
365 | }
366 |
367 | /**
368 | * Private: Calculate summary metrics
369 | */
370 | private calculateSummary(
371 | ssgStats: Map<string, DeploymentPattern>,
372 | projectCount: number,
373 | ): AnalyticsReport["summary"] {
374 | let totalDeployments = 0;
375 | let totalSuccessful = 0;
376 | let mostUsedSSG = "";
377 | let mostUsedCount = 0;
378 | let mostSuccessfulSSG = "";
379 | let highestSuccessRate = 0;
380 |
381 | for (const [ssg, stats] of ssgStats.entries()) {
382 | totalDeployments += stats.totalDeployments;
383 | totalSuccessful += stats.successfulDeployments;
384 |
385 | if (stats.totalDeployments > mostUsedCount) {
386 | mostUsedCount = stats.totalDeployments;
387 | mostUsedSSG = ssg;
388 | }
389 |
390 | if (
391 | stats.successRate > highestSuccessRate &&
392 | stats.totalDeployments >= 2
393 | ) {
394 | highestSuccessRate = stats.successRate;
395 | mostSuccessfulSSG = ssg;
396 | }
397 | }
398 |
399 | return {
400 | totalProjects: projectCount,
401 | totalDeployments,
402 | overallSuccessRate:
403 | totalDeployments > 0 ? totalSuccessful / totalDeployments : 0,
404 | mostUsedSSG: mostUsedSSG || "none",
405 | mostSuccessfulSSG: mostSuccessfulSSG || mostUsedSSG || "none",
406 | };
407 | }
408 |
409 | /**
410 | * Private: Identify patterns
411 | */
412 | private identifyPatterns(
413 | ssgStats: Map<string, DeploymentPattern>,
414 | ): DeploymentPattern[] {
415 | return Array.from(ssgStats.values()).sort(
416 | (a, b) => b.totalDeployments - a.totalDeployments,
417 | );
418 | }
419 |
420 | /**
421 | * Private: Generate insights
422 | */
423 | private generateInsights(
424 | patterns: DeploymentPattern[],
425 | summary: AnalyticsReport["summary"],
426 | ): DeploymentInsight[] {
427 | const insights: DeploymentInsight[] = [];
428 |
429 | // Overall health insight
430 | if (summary.overallSuccessRate > 0.8) {
431 | insights.push({
432 | type: "success",
433 | title: "High Success Rate",
434 | description: `Excellent! ${(summary.overallSuccessRate * 100).toFixed(
435 | 1,
436 | )}% of deployments succeed`,
437 | metric: summary.overallSuccessRate,
438 | });
439 | } else if (summary.overallSuccessRate < 0.5) {
440 | insights.push({
441 | type: "warning",
442 | title: "Low Success Rate",
443 | description: `Only ${(summary.overallSuccessRate * 100).toFixed(
444 | 1,
445 | )}% of deployments succeed. Review common failure patterns.`,
446 | metric: summary.overallSuccessRate,
447 | });
448 | }
449 |
450 | // SSG-specific insights
451 | for (const pattern of patterns) {
452 | if (pattern.successRate === 1.0 && pattern.totalDeployments >= 3) {
453 | insights.push({
454 | type: "success",
455 | title: `${pattern.ssg} Perfect Track Record`,
456 | description: `All ${pattern.totalDeployments} deployments with ${pattern.ssg} succeeded`,
457 | ssg: pattern.ssg,
458 | metric: pattern.successRate,
459 | });
460 | } else if (pattern.successRate < 0.5 && pattern.totalDeployments >= 2) {
461 | insights.push({
462 | type: "warning",
463 | title: `${pattern.ssg} Struggling`,
464 | description: `Only ${(pattern.successRate * 100).toFixed(
465 | 0,
466 | )}% success rate with ${pattern.ssg}`,
467 | ssg: pattern.ssg,
468 | metric: pattern.successRate,
469 | });
470 | }
471 |
472 | // Build time insights
473 | if (pattern.averageBuildTime) {
474 | if (pattern.averageBuildTime < 30000) {
475 | insights.push({
476 | type: "success",
477 | title: `${pattern.ssg} Fast Builds`,
478 | description: `Average build time: ${(
479 | pattern.averageBuildTime / 1000
480 | ).toFixed(1)}s`,
481 | ssg: pattern.ssg,
482 | metric: pattern.averageBuildTime,
483 | });
484 | } else if (pattern.averageBuildTime > 120000) {
485 | insights.push({
486 | type: "warning",
487 | title: `${pattern.ssg} Slow Builds`,
488 | description: `Average build time: ${(
489 | pattern.averageBuildTime / 1000
490 | ).toFixed(1)}s. Consider optimization.`,
491 | ssg: pattern.ssg,
492 | metric: pattern.averageBuildTime,
493 | });
494 | }
495 | }
496 | }
497 |
498 | return insights;
499 | }
500 |
501 | /**
502 | * Private: Generate recommendations
503 | */
504 | private generateRecommendations(
505 | patterns: DeploymentPattern[],
506 | insights: DeploymentInsight[],
507 | ): string[] {
508 | const recommendations: string[] = [];
509 |
510 | // Find best performing SSG
511 | const bestSSG = patterns.find(
512 | (p) => p.successRate > 0.8 && p.totalDeployments >= 2,
513 | );
514 | if (bestSSG) {
515 | recommendations.push(
516 | `Consider using ${bestSSG.ssg} for new projects (${(
517 | bestSSG.successRate * 100
518 | ).toFixed(0)}% success rate)`,
519 | );
520 | }
521 |
522 | // Identify problematic SSGs
523 | const problematicSSG = patterns.find(
524 | (p) => p.successRate < 0.5 && p.totalDeployments >= 3,
525 | );
526 | if (problematicSSG) {
527 | recommendations.push(
528 | `Review ${problematicSSG.ssg} deployment process - ${problematicSSG.failedDeployments} recent failures`,
529 | );
530 | }
531 |
532 | // Diversity recommendation
533 | if (patterns.length < 2) {
534 | recommendations.push(
535 | "Experiment with different SSGs to find the best fit for different project types",
536 | );
537 | }
538 |
539 | // Activity recommendation
540 | const totalDeployments = patterns.reduce(
541 | (sum, p) => sum + p.totalDeployments,
542 | 0,
543 | );
544 | if (totalDeployments < 5) {
545 | recommendations.push(
546 | "Deploy more projects to build a robust historical dataset for better recommendations",
547 | );
548 | }
549 |
550 | // Warning-based recommendations
551 | const warnings = insights.filter((i) => i.type === "warning");
552 | if (warnings.length > 2) {
553 | recommendations.push(
554 | "Multiple deployment issues detected - consider reviewing documentation setup process",
555 | );
556 | }
557 |
558 | return recommendations;
559 | }
560 | }
561 |
562 | /**
563 | * Get singleton analytics instance
564 | */
565 | let analyticsInstance: DeploymentAnalytics | null = null;
566 |
567 | export function getDeploymentAnalytics(): DeploymentAnalytics {
568 | if (!analyticsInstance) {
569 | analyticsInstance = new DeploymentAnalytics();
570 | }
571 | return analyticsInstance;
572 | }
573 |
```
--------------------------------------------------------------------------------
/tests/tools/generate-readme-template.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
2 | import { promises as fs } from "fs";
3 | import * as path from "path";
4 | import * as tmp from "tmp";
5 | import {
6 | generateReadmeTemplate,
7 | ReadmeTemplateGenerator,
8 | GenerateReadmeTemplateSchema,
9 | TemplateType,
10 | } from "../../src/tools/generate-readme-template";
11 |
12 | describe("README Template Generator", () => {
13 | let tempDir: string;
14 | let generator: ReadmeTemplateGenerator;
15 |
16 | beforeEach(() => {
17 | tempDir = tmp.dirSync({ unsafeCleanup: true }).name;
18 | generator = new ReadmeTemplateGenerator();
19 | });
20 |
21 | afterEach(async () => {
22 | try {
23 | await fs.rmdir(tempDir, { recursive: true });
24 | } catch {
25 | // Ignore cleanup errors
26 | }
27 | });
28 |
29 | describe("Input Validation", () => {
30 | it("should validate required fields", () => {
31 | expect(() => GenerateReadmeTemplateSchema.parse({})).toThrow();
32 | expect(() =>
33 | GenerateReadmeTemplateSchema.parse({
34 | projectName: "",
35 | description: "test",
36 | }),
37 | ).toThrow();
38 | expect(() =>
39 | GenerateReadmeTemplateSchema.parse({
40 | projectName: "test",
41 | description: "",
42 | }),
43 | ).toThrow();
44 | });
45 |
46 | it("should accept valid input with defaults", () => {
47 | const input = GenerateReadmeTemplateSchema.parse({
48 | projectName: "test-project",
49 | description: "A test project",
50 | templateType: "library",
51 | });
52 |
53 | expect(input.license).toBe("MIT");
54 | expect(input.includeScreenshots).toBe(false);
55 | expect(input.includeBadges).toBe(true);
56 | expect(input.includeContributing).toBe(true);
57 | });
58 |
59 | it("should validate template types", () => {
60 | expect(() =>
61 | GenerateReadmeTemplateSchema.parse({
62 | projectName: "test",
63 | description: "test",
64 | templateType: "invalid-type",
65 | }),
66 | ).toThrow();
67 |
68 | const validTypes: TemplateType[] = [
69 | "library",
70 | "application",
71 | "cli-tool",
72 | "api",
73 | "documentation",
74 | ];
75 | for (const type of validTypes) {
76 | expect(() =>
77 | GenerateReadmeTemplateSchema.parse({
78 | projectName: "test",
79 | description: "test",
80 | templateType: type,
81 | }),
82 | ).not.toThrow();
83 | }
84 | });
85 | });
86 |
87 | describe("Template Generation", () => {
88 | it("should generate library template correctly", async () => {
89 | const input = GenerateReadmeTemplateSchema.parse({
90 | projectName: "awesome-lib",
91 | description: "An awesome JavaScript library",
92 | templateType: "library",
93 | author: "john-doe",
94 | });
95 | const result = await generateReadmeTemplate(input);
96 |
97 | expect(result.content).toContain("# awesome-lib");
98 | expect(result.content).toContain("> An awesome JavaScript library");
99 | expect(result.content).toContain("npm install awesome-lib");
100 | expect(result.content).toContain(
101 | "const awesomeLib = require('awesome-lib')",
102 | );
103 | expect(result.content).toContain("## TL;DR");
104 | expect(result.content).toContain("## Quick Start");
105 | expect(result.content).toContain("## API Documentation");
106 | expect(result.content).toContain("MIT © john-doe");
107 | expect(result.metadata.templateType).toBe("library");
108 | expect(result.metadata.estimatedLength).toBe(150);
109 | });
110 |
111 | it("should generate application template correctly", async () => {
112 | const input = GenerateReadmeTemplateSchema.parse({
113 | projectName: "my-app",
114 | description: "A web application",
115 | templateType: "application",
116 | author: "jane-doe",
117 | includeScreenshots: true,
118 | });
119 | const result = await generateReadmeTemplate(input);
120 |
121 | expect(result.content).toContain("# my-app");
122 | expect(result.content).toContain("> A web application");
123 | expect(result.content).toContain("## What This Does");
124 | expect(result.content).toContain(
125 | "git clone https://github.com/jane-doe/my-app.git",
126 | );
127 | expect(result.content).toContain("npm start");
128 | expect(result.content).toContain("## Configuration");
129 | expect(result.content).toContain("![my-app Screenshot]");
130 | expect(result.metadata.templateType).toBe("application");
131 | });
132 |
133 | it("should generate CLI tool template correctly", async () => {
134 | const input = GenerateReadmeTemplateSchema.parse({
135 | projectName: "my-cli",
136 | description: "A command line tool",
137 | templateType: "cli-tool",
138 | author: "dev-user",
139 | });
140 | const result = await generateReadmeTemplate(input);
141 |
142 | expect(result.content).toContain("# my-cli");
143 | expect(result.content).toContain("npm install -g my-cli");
144 | expect(result.content).toContain("npx my-cli --help");
145 | expect(result.content).toContain("## Usage");
146 | expect(result.content).toContain("## Options");
147 | expect(result.content).toContain("| Option | Description | Default |");
148 | expect(result.metadata.templateType).toBe("cli-tool");
149 | });
150 |
151 | it("should handle camelCase conversion correctly", () => {
152 | const testCases = [
153 | { input: "my-awesome-lib", expected: "myAwesomeLib" },
154 | { input: "simple_package", expected: "simplePackage" },
155 | { input: "Mixed-Case_Name", expected: "mixedCaseName" },
156 | { input: "single", expected: "single" },
157 | ];
158 |
159 | for (const testCase of testCases) {
160 | const generator = new ReadmeTemplateGenerator();
161 | const input = GenerateReadmeTemplateSchema.parse({
162 | projectName: testCase.input,
163 | description: "test",
164 | templateType: "library",
165 | });
166 | const result = generator.generateTemplate(input);
167 |
168 | expect(result).toContain(
169 | `const ${testCase.expected} = require('${testCase.input}')`,
170 | );
171 | }
172 | });
173 | });
174 |
175 | describe("Badge Generation", () => {
176 | it("should include badges when enabled", async () => {
177 | const input = GenerateReadmeTemplateSchema.parse({
178 | projectName: "badge-lib",
179 | description: "Library with badges",
180 | templateType: "library",
181 | author: "dev",
182 | includeBadges: true,
183 | });
184 | const result = await generateReadmeTemplate(input);
185 |
186 | expect(result.content).toContain("[![npm version]");
187 | expect(result.content).toContain("[![Build Status]");
188 | expect(result.content).toContain("[![License: MIT]");
189 | expect(result.content).toContain("dev/badge-lib");
190 | });
191 |
192 | it("should exclude badges when disabled", async () => {
193 | const input = GenerateReadmeTemplateSchema.parse({
194 | projectName: "no-badge-lib",
195 | description: "Library without badges",
196 | templateType: "library",
197 | includeBadges: false,
198 | });
199 | const result = await generateReadmeTemplate(input);
200 |
201 | expect(result.content).not.toContain("[",
230 | );
231 | expect(result.content).toContain("*Add a screenshot or demo GIF here*");
232 | });
233 |
234 | it("should exclude screenshots when disabled", async () => {
235 | const input = GenerateReadmeTemplateSchema.parse({
236 | projectName: "no-screenshot-app",
237 | description: "App without screenshots",
238 | templateType: "application",
239 | includeScreenshots: false,
240 | });
241 | const result = await generateReadmeTemplate(input);
242 |
243 | expect(result.content).not.toContain("![visual-app Screenshot]");
244 | });
245 | });
246 |
247 | describe("Contributing Section", () => {
248 | it("should include contributing section when enabled", async () => {
249 | const input = GenerateReadmeTemplateSchema.parse({
250 | projectName: "contrib-lib",
251 | description: "Library with contributing section",
252 | templateType: "library",
253 | includeContributing: true,
254 | });
255 | const result = await generateReadmeTemplate(input);
256 |
257 | expect(result.content).toContain("## Contributing");
258 | expect(result.content).toContain("CONTRIBUTING.md");
259 | });
260 |
261 | it("should exclude contributing section when disabled", async () => {
262 | const input = GenerateReadmeTemplateSchema.parse({
263 | projectName: "no-contrib-lib",
264 | description: "Library without contributing section",
265 | templateType: "library",
266 | includeContributing: false,
267 | });
268 | const result = await generateReadmeTemplate(input);
269 |
270 | expect(result.content).not.toContain("## Contributing");
271 | });
272 | });
273 |
274 | describe("File Output", () => {
275 | it("should write to file when outputPath is specified", async () => {
276 | const outputPath = path.join(tempDir, "README.md");
277 |
278 | const input = GenerateReadmeTemplateSchema.parse({
279 | projectName: "output-lib",
280 | description: "Library with file output",
281 | templateType: "library",
282 | outputPath: outputPath,
283 | });
284 | const result = await generateReadmeTemplate(input);
285 |
286 | await expect(fs.access(outputPath)).resolves.toBeUndefined();
287 | const fileContent = await fs.readFile(outputPath, "utf-8");
288 | expect(fileContent).toBe(result.content);
289 | expect(fileContent).toContain("# output-lib");
290 | });
291 |
292 | it("should not write file when outputPath is not specified", async () => {
293 | const input = GenerateReadmeTemplateSchema.parse({
294 | projectName: "no-file-test",
295 | description: "Library without file output",
296 | templateType: "library",
297 | });
298 | await generateReadmeTemplate(input);
299 |
300 | const possiblePath = path.join(tempDir, "README.md");
301 | await expect(fs.access(possiblePath)).rejects.toThrow();
302 | });
303 | });
304 |
305 | describe("Template Metadata", () => {
306 | it("should return correct metadata for each template type", () => {
307 | const templateTypes: TemplateType[] = [
308 | "library",
309 | "application",
310 | "cli-tool",
311 | ];
312 |
313 | for (const type of templateTypes) {
314 | const info = generator.getTemplateInfo(type);
315 | expect(info).toBeDefined();
316 | expect(info!.type).toBe(type);
317 | expect(info!.estimatedLength).toBeGreaterThan(0);
318 | }
319 | });
320 |
321 | it("should return null for invalid template type", () => {
322 | const info = generator.getTemplateInfo("invalid" as TemplateType);
323 | expect(info).toBeNull();
324 | });
325 |
326 | it("should count sections correctly", async () => {
327 | const input = GenerateReadmeTemplateSchema.parse({
328 | projectName: "error-lib",
329 | description: "Library that causes error",
330 | templateType: "library",
331 | });
332 | const result = await generateReadmeTemplate(input);
333 |
334 | const sectionCount = (result.content.match(/^##\s/gm) || []).length;
335 | expect(result.metadata.sectionsIncluded).toBeGreaterThanOrEqual(
336 | sectionCount,
337 | );
338 | expect(result.metadata.sectionsIncluded).toBeGreaterThan(3);
339 | });
340 | });
341 |
342 | describe("Available Templates", () => {
343 | it("should return list of available template types", () => {
344 | const availableTypes = generator.getAvailableTemplates();
345 | expect(availableTypes).toContain("library");
346 | expect(availableTypes).toContain("application");
347 | expect(availableTypes).toContain("cli-tool");
348 | expect(availableTypes.length).toBeGreaterThan(0);
349 | });
350 | });
351 |
352 | describe("Error Handling", () => {
353 | it("should throw error for unsupported template type", async () => {
354 | const generator = new ReadmeTemplateGenerator();
355 | expect(() =>
356 | generator.generateTemplate({
357 | projectName: "test",
358 | description: "test",
359 | templateType: "unsupported" as TemplateType,
360 | license: "MIT",
361 | includeScreenshots: false,
362 | includeBadges: true,
363 | includeContributing: true,
364 | }),
365 | ).toThrow('Template type "unsupported" not supported');
366 | });
367 |
368 | it("should handle file write errors gracefully", async () => {
369 | const invalidPath = "/invalid/nonexistent/path/README.md";
370 |
371 | const input = GenerateReadmeTemplateSchema.parse({
372 | projectName: "error-test",
373 | description: "test error handling",
374 | templateType: "library",
375 | outputPath: invalidPath,
376 | });
377 | await expect(generateReadmeTemplate(input)).rejects.toThrow();
378 | });
379 | });
380 |
381 | describe("Variable Replacement", () => {
382 | it("should replace all template variables correctly", async () => {
383 | const input = GenerateReadmeTemplateSchema.parse({
384 | projectName: "license-lib",
385 | description: "Library with custom license",
386 | templateType: "library",
387 | author: "dev",
388 | license: "Apache-2.0",
389 | });
390 | const result = await generateReadmeTemplate(input);
391 |
392 | expect(result.content).not.toContain("{{projectName}}");
393 | expect(result.content).not.toContain("{{description}}");
394 | expect(result.content).not.toContain("{{author}}");
395 | expect(result.content).not.toContain("{{license}}");
396 | expect(result.content).toContain("license-lib");
397 | expect(result.content).toContain("Library with custom license");
398 | expect(result.content).toContain("dev");
399 | expect(result.content).toContain("Apache-2.0");
400 | });
401 |
402 | it("should use default values for missing optional fields", async () => {
403 | const input = GenerateReadmeTemplateSchema.parse({
404 | projectName: "time-lib",
405 | description: "Library with timing",
406 | templateType: "library",
407 | });
408 | const result = await generateReadmeTemplate(input);
409 |
410 | expect(result.content).toContain("your-username");
411 | expect(result.content).toContain("MIT");
412 | });
413 | });
414 |
415 | describe("Template Structure Validation", () => {
416 | it("should generate valid markdown structure", async () => {
417 | const input = GenerateReadmeTemplateSchema.parse({
418 | projectName: "structure-test",
419 | description: "test structure",
420 | templateType: "library",
421 | });
422 | const result = await generateReadmeTemplate(input);
423 |
424 | // Check for proper heading hierarchy
425 | const lines = result.content.split("\n");
426 | const headings = lines.filter((line) => line.startsWith("#"));
427 |
428 | expect(headings.length).toBeGreaterThan(0);
429 | expect(headings[0]).toMatch(/^#\s+/); // Main title
430 |
431 | // Check for code blocks
432 | expect(result.content).toMatch(/```[\s\S]*?```/);
433 |
434 | // Check for proper spacing
435 | expect(result.content).not.toMatch(/#{1,6}\s*\n\s*#{1,6}/);
436 | });
437 |
438 | it("should maintain consistent formatting across templates", async () => {
439 | const templateTypes: TemplateType[] = [
440 | "library",
441 | "application",
442 | "cli-tool",
443 | ];
444 |
445 | for (const type of templateTypes) {
446 | const input = GenerateReadmeTemplateSchema.parse({
447 | projectName: "format-test",
448 | description: "test format",
449 | templateType: type,
450 | });
451 | const result = await generateReadmeTemplate(input);
452 |
453 | // All templates should have main title
454 | expect(result.content).toMatch(/^#\s+format-test/m);
455 |
456 | // All templates should have license section
457 | expect(result.content).toContain("## License");
458 |
459 | // All templates should end with license info
460 | expect(result.content.trim()).toMatch(/MIT © your-username$/);
461 | }
462 | });
463 | });
464 | });
465 |
```
--------------------------------------------------------------------------------
/tests/memory/user-preferences.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Tests for User Preference Management
3 | */
4 |
5 | import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
6 | import { promises as fs } from "fs";
7 | import { join } from "path";
8 | import { tmpdir } from "os";
9 | import {
10 | UserPreferenceManager,
11 | getUserPreferenceManager,
12 | clearPreferenceManagerCache,
13 | } from "../../src/memory/user-preferences.js";
14 | import {
15 | getKnowledgeGraph,
16 | initializeKnowledgeGraph,
17 | } from "../../src/memory/kg-integration.js";
18 |
19 | describe("UserPreferenceManager", () => {
20 | let testDir: string;
21 |
22 | beforeEach(async () => {
23 | // Create temporary test directory
24 | testDir = join(tmpdir(), `user-prefs-test-${Date.now()}`);
25 | await fs.mkdir(testDir, { recursive: true });
26 |
27 | // Initialize KG with test directory
28 | await initializeKnowledgeGraph(testDir);
29 | clearPreferenceManagerCache();
30 | });
31 |
32 | afterEach(async () => {
33 | clearPreferenceManagerCache();
34 | // Clean up test directory
35 | try {
36 | await fs.rm(testDir, { recursive: true, force: true });
37 | } catch (error) {
38 | // Ignore cleanup errors
39 | }
40 | });
41 |
42 | describe("Initialization", () => {
43 | it("should create default preferences for new user", async () => {
44 | const manager = new UserPreferenceManager("test-user");
45 | await manager.initialize();
46 |
47 | const prefs = await manager.getPreferences();
48 | expect(prefs.userId).toBe("test-user");
49 | expect(prefs.preferredSSGs).toEqual([]);
50 | expect(prefs.documentationStyle).toBe("comprehensive");
51 | expect(prefs.expertiseLevel).toBe("intermediate");
52 | expect(prefs.autoApplyPreferences).toBe(true);
53 | });
54 |
55 | it("should load existing preferences from knowledge graph", async () => {
56 | // Create a user with preferences
57 | const kg = await getKnowledgeGraph();
58 | kg.addNode({
59 | id: "user:existing-user",
60 | type: "user",
61 | label: "existing-user",
62 | properties: {
63 | userId: "existing-user",
64 | preferredSSGs: ["jekyll", "hugo"],
65 | documentationStyle: "minimal",
66 | expertiseLevel: "advanced",
67 | preferredTechnologies: ["typescript"],
68 | preferredDiataxisCategories: ["tutorials"],
69 | autoApplyPreferences: false,
70 | lastActive: "2025-01-01T00:00:00.000Z",
71 | },
72 | weight: 1.0,
73 | });
74 |
75 | const manager = new UserPreferenceManager("existing-user");
76 | await manager.initialize();
77 |
78 | const prefs = await manager.getPreferences();
79 | expect(prefs.userId).toBe("existing-user");
80 | expect(prefs.preferredSSGs).toEqual(["jekyll", "hugo"]);
81 | expect(prefs.documentationStyle).toBe("minimal");
82 | expect(prefs.expertiseLevel).toBe("advanced");
83 | expect(prefs.autoApplyPreferences).toBe(false);
84 | });
85 |
86 | it("should handle getPreferences before initialization", async () => {
87 | const manager = new UserPreferenceManager("auto-init");
88 | const prefs = await manager.getPreferences();
89 |
90 | expect(prefs.userId).toBe("auto-init");
91 | expect(prefs.preferredSSGs).toEqual([]);
92 | });
93 | });
94 |
95 | describe("Update Preferences", () => {
96 | it("should update preferences and save to knowledge graph", async () => {
97 | const manager = new UserPreferenceManager("update-test");
98 | await manager.initialize();
99 |
100 | await manager.updatePreferences({
101 | documentationStyle: "tutorial-heavy",
102 | expertiseLevel: "beginner",
103 | preferredTechnologies: ["python", "go"],
104 | });
105 |
106 | const prefs = await manager.getPreferences();
107 | expect(prefs.documentationStyle).toBe("tutorial-heavy");
108 | expect(prefs.expertiseLevel).toBe("beginner");
109 | expect(prefs.preferredTechnologies).toEqual(["python", "go"]);
110 | });
111 |
112 | it("should initialize before update if not already initialized", async () => {
113 | const manager = new UserPreferenceManager("lazy-init");
114 |
115 | await manager.updatePreferences({
116 | expertiseLevel: "advanced",
117 | });
118 |
119 | const prefs = await manager.getPreferences();
120 | expect(prefs.expertiseLevel).toBe("advanced");
121 | });
122 | });
123 |
124 | describe("Track SSG Usage", () => {
125 | it("should track successful SSG usage and create preference", async () => {
126 | const manager = new UserPreferenceManager("ssg-tracker");
127 | await manager.initialize();
128 |
129 | await manager.trackSSGUsage({
130 | ssg: "jekyll",
131 | success: true,
132 | timestamp: "2025-01-01T00:00:00.000Z",
133 | });
134 |
135 | const prefs = await manager.getPreferences();
136 | expect(prefs.preferredSSGs).toContain("jekyll");
137 | });
138 |
139 | it("should track failed SSG usage", async () => {
140 | const manager = new UserPreferenceManager("fail-tracker");
141 | await manager.initialize();
142 |
143 | await manager.trackSSGUsage({
144 | ssg: "hugo",
145 | success: false,
146 | timestamp: "2025-01-01T00:00:00.000Z",
147 | });
148 |
149 | const kg = await getKnowledgeGraph();
150 | const edges = await kg.findEdges({
151 | type: "user_prefers_ssg",
152 | });
153 |
154 | expect(edges.length).toBeGreaterThan(0);
155 | const edge = edges.find((e) => e.target.includes("hugo"));
156 | expect(edge).toBeDefined();
157 | expect(edge!.weight).toBe(0.5); // Failed usage has lower weight
158 | });
159 |
160 | it("should update existing SSG preference", async () => {
161 | const manager = new UserPreferenceManager("update-tracker");
162 | await manager.initialize();
163 |
164 | // First usage - success
165 | await manager.trackSSGUsage({
166 | ssg: "docusaurus",
167 | success: true,
168 | timestamp: "2025-01-01T00:00:00.000Z",
169 | });
170 |
171 | // Second usage - success
172 | await manager.trackSSGUsage({
173 | ssg: "docusaurus",
174 | success: true,
175 | timestamp: "2025-01-02T00:00:00.000Z",
176 | });
177 |
178 | const kg = await getKnowledgeGraph();
179 | const edges = await kg.findEdges({
180 | type: "user_prefers_ssg",
181 | });
182 |
183 | const docEdge = edges.find((e) => e.target.includes("docusaurus"));
184 | expect(docEdge!.properties.usageCount).toBe(2);
185 | expect(docEdge!.properties.successRate).toBe(1.0);
186 | });
187 |
188 | it("should calculate average success rate correctly", async () => {
189 | const manager = new UserPreferenceManager("avg-tracker");
190 | await manager.initialize();
191 |
192 | // Success
193 | await manager.trackSSGUsage({
194 | ssg: "mkdocs",
195 | success: true,
196 | timestamp: "2025-01-01T00:00:00.000Z",
197 | });
198 |
199 | // Failure
200 | await manager.trackSSGUsage({
201 | ssg: "mkdocs",
202 | success: false,
203 | timestamp: "2025-01-02T00:00:00.000Z",
204 | });
205 |
206 | const kg = await getKnowledgeGraph();
207 | const edges = await kg.findEdges({
208 | type: "user_prefers_ssg",
209 | });
210 |
211 | const mkdocsEdge = edges.find((e) => e.target.includes("mkdocs"));
212 | expect(mkdocsEdge!.properties.successRate).toBe(0.5);
213 | });
214 |
215 | it("should create user node if it doesn't exist during tracking", async () => {
216 | const manager = new UserPreferenceManager("new-tracker");
217 | // Don't initialize - let trackSSGUsage create it
218 |
219 | await manager.trackSSGUsage({
220 | ssg: "eleventy",
221 | success: true,
222 | timestamp: "2025-01-01T00:00:00.000Z",
223 | });
224 |
225 | const kg = await getKnowledgeGraph();
226 | const userNode = await kg.findNode({
227 | type: "user",
228 | properties: { userId: "new-tracker" },
229 | });
230 |
231 | expect(userNode).toBeDefined();
232 | });
233 | });
234 |
235 | describe("SSG Recommendations", () => {
236 | it("should return recommendations sorted by score", async () => {
237 | const manager = new UserPreferenceManager("rec-test");
238 | await manager.initialize();
239 |
240 | // Track multiple SSGs with different success rates
241 | await manager.trackSSGUsage({
242 | ssg: "jekyll",
243 | success: true,
244 | timestamp: "2025-01-01T00:00:00.000Z",
245 | });
246 | await manager.trackSSGUsage({
247 | ssg: "jekyll",
248 | success: true,
249 | timestamp: "2025-01-02T00:00:00.000Z",
250 | });
251 | await manager.trackSSGUsage({
252 | ssg: "hugo",
253 | success: true,
254 | timestamp: "2025-01-03T00:00:00.000Z",
255 | });
256 |
257 | const recommendations = await manager.getSSGRecommendations();
258 |
259 | expect(recommendations.length).toBeGreaterThan(0);
260 | expect(recommendations[0].ssg).toBe("jekyll"); // Higher usage count
261 | expect(recommendations[0].score).toBeGreaterThan(
262 | recommendations[1].score,
263 | );
264 | });
265 |
266 | it("should include reason with high success rate", async () => {
267 | const manager = new UserPreferenceManager("reason-test");
268 | await manager.initialize();
269 |
270 | await manager.trackSSGUsage({
271 | ssg: "docusaurus",
272 | success: true,
273 | timestamp: "2025-01-01T00:00:00.000Z",
274 | });
275 |
276 | const recommendations = await manager.getSSGRecommendations();
277 | const docRec = recommendations.find((r) => r.ssg === "docusaurus");
278 |
279 | expect(docRec!.reason).toContain("100% success rate");
280 | });
281 |
282 | it("should include reason with low success rate", async () => {
283 | const manager = new UserPreferenceManager("low-success-test");
284 | await manager.initialize();
285 |
286 | // Track both success and failure to get a low rate (not exactly 0)
287 | await manager.trackSSGUsage({
288 | ssg: "eleventy",
289 | success: true,
290 | timestamp: "2025-01-01T00:00:00.000Z",
291 | });
292 | await manager.trackSSGUsage({
293 | ssg: "eleventy",
294 | success: false,
295 | timestamp: "2025-01-02T00:00:00.000Z",
296 | });
297 | await manager.trackSSGUsage({
298 | ssg: "eleventy",
299 | success: false,
300 | timestamp: "2025-01-03T00:00:00.000Z",
301 | });
302 |
303 | const recommendations = await manager.getSSGRecommendations();
304 | const eleventyRec = recommendations.find((r) => r.ssg === "eleventy");
305 |
306 | expect(eleventyRec!.reason).toContain("only");
307 | expect(eleventyRec!.reason).toContain("success rate");
308 | });
309 |
310 | it("should return empty array if no user node exists", async () => {
311 | const manager = new UserPreferenceManager("no-user");
312 | // Don't initialize or create user node
313 |
314 | const recommendations = await manager.getSSGRecommendations();
315 |
316 | expect(recommendations).toEqual([]);
317 | });
318 | });
319 |
320 | describe("Apply Preferences to Recommendation", () => {
321 | it("should return original recommendation if autoApply is false", async () => {
322 | const manager = new UserPreferenceManager("no-auto");
323 | await manager.updatePreferences({
324 | autoApplyPreferences: false,
325 | preferredSSGs: ["jekyll"],
326 | });
327 |
328 | const result = manager.applyPreferencesToRecommendation("hugo", [
329 | "jekyll",
330 | "hugo",
331 | ]);
332 |
333 | expect(result.recommended).toBe("hugo");
334 | expect(result.adjustmentReason).toBeUndefined();
335 | });
336 |
337 | it("should keep recommendation if it matches preferred SSG", async () => {
338 | const manager = new UserPreferenceManager("match-pref");
339 | await manager.updatePreferences({
340 | preferredSSGs: ["jekyll", "hugo"],
341 | });
342 |
343 | const result = manager.applyPreferencesToRecommendation("jekyll", [
344 | "jekyll",
345 | "hugo",
346 | "mkdocs",
347 | ]);
348 |
349 | expect(result.recommended).toBe("jekyll");
350 | expect(result.adjustmentReason).toContain("Matches your preferred SSG");
351 | });
352 |
353 | it("should switch to preferred SSG if in alternatives", async () => {
354 | const manager = new UserPreferenceManager("switch-pref");
355 | await manager.updatePreferences({
356 | preferredSSGs: ["docusaurus"],
357 | });
358 |
359 | const result = manager.applyPreferencesToRecommendation("jekyll", [
360 | "jekyll",
361 | "docusaurus",
362 | "hugo",
363 | ]);
364 |
365 | expect(result.recommended).toBe("docusaurus");
366 | expect(result.adjustmentReason).toContain(
367 | "Switched to docusaurus based on your usage history",
368 | );
369 | });
370 |
371 | it("should return original if no preferred SSGs match", async () => {
372 | const manager = new UserPreferenceManager("no-match");
373 | await manager.updatePreferences({
374 | preferredSSGs: ["eleventy"],
375 | });
376 |
377 | const result = manager.applyPreferencesToRecommendation("jekyll", [
378 | "jekyll",
379 | "hugo",
380 | ]);
381 |
382 | expect(result.recommended).toBe("jekyll");
383 | expect(result.adjustmentReason).toBeUndefined();
384 | });
385 |
386 | it("should return original if no preferences set", async () => {
387 | const manager = new UserPreferenceManager("empty-pref");
388 | await manager.initialize();
389 |
390 | const result = manager.applyPreferencesToRecommendation("jekyll", [
391 | "jekyll",
392 | "hugo",
393 | ]);
394 |
395 | expect(result.recommended).toBe("jekyll");
396 | expect(result.adjustmentReason).toBeUndefined();
397 | });
398 | });
399 |
400 | describe("Reset Preferences", () => {
401 | it("should reset preferences to defaults", async () => {
402 | const manager = new UserPreferenceManager("reset-test");
403 | await manager.updatePreferences({
404 | documentationStyle: "minimal",
405 | expertiseLevel: "advanced",
406 | preferredSSGs: ["jekyll", "hugo"],
407 | });
408 |
409 | await manager.resetPreferences();
410 |
411 | const prefs = await manager.getPreferences();
412 | expect(prefs.documentationStyle).toBe("comprehensive");
413 | expect(prefs.expertiseLevel).toBe("intermediate");
414 | expect(prefs.preferredSSGs).toEqual([]);
415 | });
416 | });
417 |
418 | describe("Export/Import Preferences", () => {
419 | it("should export preferences as JSON", async () => {
420 | const manager = new UserPreferenceManager("export-test");
421 | await manager.updatePreferences({
422 | expertiseLevel: "advanced",
423 | preferredSSGs: ["jekyll"],
424 | });
425 |
426 | const exported = await manager.exportPreferences();
427 | const parsed = JSON.parse(exported);
428 |
429 | expect(parsed.userId).toBe("export-test");
430 | expect(parsed.expertiseLevel).toBe("advanced");
431 | expect(parsed.preferredSSGs).toEqual(["jekyll"]);
432 | });
433 |
434 | it("should import preferences from JSON", async () => {
435 | const manager = new UserPreferenceManager("import-test");
436 | await manager.initialize();
437 |
438 | const importData = {
439 | userId: "import-test",
440 | preferredSSGs: ["hugo", "docusaurus"],
441 | documentationStyle: "tutorial-heavy" as const,
442 | expertiseLevel: "beginner" as const,
443 | preferredTechnologies: ["python"],
444 | preferredDiataxisCategories: ["tutorials" as const],
445 | autoApplyPreferences: false,
446 | lastUpdated: "2025-01-01T00:00:00.000Z",
447 | };
448 |
449 | await manager.importPreferences(JSON.stringify(importData));
450 |
451 | const prefs = await manager.getPreferences();
452 | expect(prefs.expertiseLevel).toBe("beginner");
453 | expect(prefs.preferredSSGs).toEqual(["hugo", "docusaurus"]);
454 | expect(prefs.autoApplyPreferences).toBe(false);
455 | });
456 |
457 | it("should throw error on userId mismatch during import", async () => {
458 | const manager = new UserPreferenceManager("user1");
459 | await manager.initialize();
460 |
461 | const importData = {
462 | userId: "user2", // Different user ID
463 | preferredSSGs: [],
464 | documentationStyle: "comprehensive" as const,
465 | expertiseLevel: "intermediate" as const,
466 | preferredTechnologies: [],
467 | preferredDiataxisCategories: [],
468 | autoApplyPreferences: true,
469 | lastUpdated: "2025-01-01T00:00:00.000Z",
470 | };
471 |
472 | await expect(
473 | manager.importPreferences(JSON.stringify(importData)),
474 | ).rejects.toThrow("User ID mismatch");
475 | });
476 | });
477 |
478 | describe("Manager Cache", () => {
479 | it("should cache preference managers", async () => {
480 | const manager1 = await getUserPreferenceManager("cached-user");
481 | const manager2 = await getUserPreferenceManager("cached-user");
482 |
483 | expect(manager1).toBe(manager2); // Same instance
484 | });
485 |
486 | it("should create different managers for different users", async () => {
487 | const manager1 = await getUserPreferenceManager("user1");
488 | const manager2 = await getUserPreferenceManager("user2");
489 |
490 | expect(manager1).not.toBe(manager2);
491 | });
492 |
493 | it("should clear cache", async () => {
494 | const manager1 = await getUserPreferenceManager("clear-test");
495 | clearPreferenceManagerCache();
496 | const manager2 = await getUserPreferenceManager("clear-test");
497 |
498 | expect(manager1).not.toBe(manager2); // Different instances after clear
499 | });
500 | });
501 | });
502 |
```