├── .nvmrc ├── .gitignore ├── .husky └── commit-msg ├── .release-please-manifest.json ├── .vscode └── settings.json ├── tweet.png ├── commitlint.config.js ├── src ├── util.ts ├── tests │ ├── __mocks__ │ │ ├── doc-formatter.service.mock.ts │ │ ├── link-extractor.service.mock.ts │ │ ├── doc-parser.service.mock.ts │ │ ├── config.mock.ts │ │ ├── file-system.service.mock.ts │ │ └── doc-index.service.mock.ts │ ├── doc-parser.service.test.ts │ ├── doc-formatter.service.test.ts │ ├── doc-context.service.integration.test.ts │ ├── doc-index.service.test.ts │ └── link-extractor.service.test.ts ├── logger.ts ├── index.ts ├── config.ts ├── types.ts ├── file-system.service.ts ├── doc-parser.service.ts ├── doc-formatter.service.ts ├── link-extractor.service.ts ├── doc-index.service.ts ├── doc-server.ts └── doc-context.service.ts ├── .cursor └── mcp.json ├── tsconfig.test.json ├── .prettierrc ├── vitest.config.ts ├── setup.tests.ts ├── .npmignore ├── release-please-config.json ├── Dockerfile ├── tsconfig.json ├── wsl-start-server.sh ├── markdown-rules.md ├── LICENSE ├── CONTRIBUTING.md ├── .github └── workflows │ ├── main.yaml │ └── pull-request.yaml ├── smithery.yaml ├── docs └── project-rules.md ├── package.json ├── CHANGELOG.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | *.log 4 | .env* -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit ${1} 2 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.4.6" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /tweet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valstro/markdown-rules-mcp/HEAD/tweet.png -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export function getErrorMsg(error: unknown): string { 2 | return error instanceof Error ? error.message : String(error); 3 | } 4 | -------------------------------------------------------------------------------- /.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "markdown-rules-wsl": { 4 | "command": "wsl.exe", 5 | "args": ["-e", "bash", "-c", "./wsl-start-server.sh"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["vitest/globals", "node"] 5 | }, 6 | "include": ["setup.tests.ts", "src/**/*.test.ts", "src/**/*.mock.ts"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "es5", 8 | "bracketSpacing": true, 9 | "arrowParens": "always", 10 | "ignore": ["build/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: "node", 7 | setupFiles: "setup.tests.ts", 8 | include: ["src/**/*.test.ts"], 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /src/tests/__mocks__/doc-formatter.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { vi, Mocked } from "vitest"; 2 | import { IDocFormatterService } from "../../types.js"; 3 | 4 | export const createMockDocFormatterService = (): Mocked => ({ 5 | formatContextOutput: vi.fn(), 6 | formatDoc: vi.fn(), 7 | }); 8 | -------------------------------------------------------------------------------- /setup.tests.ts: -------------------------------------------------------------------------------- 1 | import { Mocked, vi } from "vitest"; 2 | 3 | vi.mock("./logger.js", () => ({ 4 | logger: { 5 | debug: vi.fn(), 6 | info: vi.fn(), 7 | warn: vi.fn(), 8 | error: vi.fn(), 9 | }, 10 | })); 11 | 12 | export function unwrapMock(mock: Mocked): T { 13 | return mock as unknown as T; 14 | } 15 | -------------------------------------------------------------------------------- /src/tests/__mocks__/link-extractor.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { vi, Mocked } from "vitest"; 2 | import { ILinkExtractorService } from "../../types.js"; 3 | 4 | export function createMockLinkExtractorService(): Mocked { 5 | return { 6 | extractLinks: vi.fn().mockReturnValue([]), // Default to no links 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /src/tests/__mocks__/doc-parser.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { vi, Mocked } from "vitest"; 2 | import { IDocParserService } from "../../types.js"; 3 | 4 | export function createMockDocParserService(): Mocked { 5 | return { 6 | parse: vi.fn(), 7 | getBlankDoc: vi.fn(), 8 | isMarkdown: vi.fn((fileName) => fileName.toLowerCase().endsWith(".md")), 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | tests/ 3 | .github/ 4 | .gitignore 5 | .npmignore 6 | .nvmrc 7 | tsconfig.json 8 | .cursor/ 9 | .vscode/ 10 | docs/ 11 | *.log 12 | .env* 13 | setup.tests.ts 14 | vitest.config.ts 15 | tsconfig.test.json 16 | wsl-start-server.sh 17 | smithery.yaml 18 | .prettierrc 19 | .husky 20 | .release-please-manifest.json 21 | release-please-config.json 22 | .nvmrc 23 | commitlint.config.js -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "component": "markdown-rules-mcp", 5 | "changelog-path": "CHANGELOG.md", 6 | "release-type": "node", 7 | "bump-minor-pre-major": false, 8 | "bump-patch-for-minor-pre-major": false, 9 | "draft": false, 10 | "prerelease": false 11 | } 12 | }, 13 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 14 | } 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | # Create app directory 4 | WORKDIR /usr/src/app 5 | 6 | # Copy package.json and package-lock.json 7 | COPY package*.json ./ 8 | 9 | # Install dependencies without triggering any unwanted scripts 10 | RUN npm install --ignore-scripts 11 | 12 | # Copy all source code 13 | COPY . . 14 | 15 | # Build the application 16 | RUN npm run build 17 | 18 | # Expose port if needed (not specified, so using none) 19 | 20 | # Command to run the server 21 | CMD [ "node", "build/index.js" ] -------------------------------------------------------------------------------- /src/tests/__mocks__/config.mock.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "../../config"; 2 | 3 | export const createConfigMock = (partialConfig: Partial): Config => ({ 4 | PROJECT_ROOT: "/project", 5 | MARKDOWN_INCLUDE: "**/*.md", 6 | MARKDOWN_EXCLUDE: 7 | "**/node_modules/**,**/build/**,**/dist/**,**/.git/**,**/coverage/**,**/.next/**,**/.nuxt/**,**/out/**,**/.cache/**,**/tmp/**,**/temp/**", 8 | USAGE_INSTRUCTIONS_PATH: "markdown-rules.md", 9 | LOG_LEVEL: "error", 10 | HOIST_CONTEXT: true, 11 | ...partialConfig, 12 | }); 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./build", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "declaration": true, 13 | "declarationMap": true, 14 | "resolveJsonModule": true 15 | }, 16 | "include": ["src/**/*"], 17 | "exclude": ["node_modules", "src/**/*.test.ts", "src/**/*.mock.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /src/tests/__mocks__/file-system.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { vi, Mocked } from "vitest"; 2 | import { IFileSystemService } from "../../types.js"; 3 | 4 | export function createMockFileSystemService(): Mocked { 5 | return { 6 | findFiles: vi.fn(), 7 | readFile: vi.fn(), 8 | resolvePath: vi.fn((...paths) => paths.join("/")), // Simple path join for tests 9 | getDirname: vi.fn((filePath) => filePath.substring(0, filePath.lastIndexOf("/"))), 10 | getProjectRoot: vi.fn(() => "/project"), 11 | pathExists: vi.fn(), 12 | getRelativePath: vi.fn((filePath) => filePath), 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /wsl-start-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Try to source profile to get node version manager setup 4 | if [ -f "$HOME/.bashrc" ]; then 5 | source "$HOME/.bashrc" > /dev/null 2>&1 6 | elif [ -f "$HOME/.bash_profile" ]; then 7 | source "$HOME/.bash_profile" > /dev/null 2>&1 8 | fi 9 | 10 | # For NVM users 11 | if [ -f "$HOME/.nvm/nvm.sh" ]; then 12 | source "$HOME/.nvm/nvm.sh" > /dev/null 2>&1 13 | fi 14 | 15 | # For Volta users (fallback if not in PATH already) 16 | if [ -d "$HOME/.volta/bin" ]; then 17 | export PATH="$HOME/.volta/bin:$PATH" 18 | fi 19 | 20 | # Get script location and navigate to project root 21 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 22 | PROJECT_ROOT="$( cd "$SCRIPT_DIR/" && pwd )" 23 | cd "$PROJECT_ROOT" 24 | 25 | # Install the package globally 26 | npm install @valstro/markdown-rules-mcp@latest -g --silent 27 | 28 | # Run the package with the correct bin name 29 | MARKDOWN_INCLUDE="./docs/**/*.md" markdown-rules-mcp 30 | -------------------------------------------------------------------------------- /markdown-rules.md: -------------------------------------------------------------------------------- 1 | # Usage Instructions 2 | 3 | ## When to use "get_relevant_docs" tool 4 | 5 | * You **must** call the "get_relevant_docs" MCP tool before providing your first response in any new chat session. 6 | * After the initial call in a chat, you should **only** call "get_relevant_docs" again if one of these specific situations occurs: 7 | * The user explicitly requests it. 8 | * The user attaches new files. 9 | * The user's query introduces a completely new topic unrelated to the previous discussion. 10 | 11 | ## How to use "get_relevant_docs" tool 12 | 13 | * "attachedFiles": ALWAYS include file paths the user has attached in their query. 14 | * "projectDocs" 15 | * ONLY include project docs that are VERY RELEVANT to user's query. 16 | * You must have a high confidence when picking docs that may be relevant. 17 | * If the user's query is a generic question unrelated to this specific project, leave this empty. 18 | * Always heavily bias towards leaving this empty. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Valstro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { config } from "./config.js"; 2 | 3 | const logLevel = config.LOG_LEVEL; 4 | 5 | const logLevelMap = { 6 | silent: 0, 7 | debug: 1, 8 | info: 2, 9 | warn: 3, 10 | error: 4, 11 | }; 12 | 13 | const currentLogLevel = logLevelMap[logLevel as keyof typeof logLevelMap] || logLevelMap.info; 14 | 15 | export const logger = { 16 | log: (message: string) => { 17 | if (currentLogLevel <= logLevelMap.info) { 18 | console.error(`[MD-INFO] ${message}`); 19 | } 20 | }, 21 | info: (message: string) => { 22 | if (currentLogLevel <= logLevelMap.info) { 23 | console.error(`[MD-INFO] ${message}`); 24 | } 25 | }, 26 | debug: (message: string) => { 27 | if (currentLogLevel <= logLevelMap.debug) { 28 | console.error(`[MD-DEBUG] ${message}`); 29 | } 30 | }, 31 | error: (message: string) => { 32 | if (currentLogLevel <= logLevelMap.error) { 33 | console.error(`[MD-ERROR] ${message}`); 34 | } 35 | }, 36 | warn: (message: string) => { 37 | if (currentLogLevel <= logLevelMap.warn) { 38 | console.error(`[MD-WARN] ${message}`); 39 | } 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/tests/__mocks__/doc-index.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { vi, Mocked } from "vitest"; 2 | import { IDocIndexService, Doc } from "../../types.js"; 3 | 4 | export function createMockDocIndexService(): Mocked { 5 | return { 6 | getDoc: vi.fn(), 7 | buildIndex: vi.fn(), 8 | loadInitialDocs: vi.fn(), 9 | recursivelyResolveAndLoadLinks: vi.fn(), 10 | getDocs: vi.fn(), 11 | getAgentAttachableDocs: vi.fn(), 12 | getDocsByType: vi.fn(), 13 | getDocMap: vi.fn(() => new Map()), 14 | docs: [], 15 | }; 16 | } 17 | 18 | export function createMockDoc(filePath: string, options: Partial = {}): Doc { 19 | const { content = "", linksTo = [], isMarkdown = true, isError = false, errorReason } = options; 20 | return { 21 | filePath, 22 | content, 23 | linksTo, 24 | isMarkdown, 25 | isError, 26 | contentLinesBeforeParsed: content.split("\n").length, 27 | meta: options.meta ?? { description: undefined, globs: [], alwaysApply: false }, // Use provided meta or default 28 | // Prioritize passed errorReason. If not passed, use default logic based on isError. 29 | errorReason: errorReason !== undefined ? errorReason : isError ? "Mock Error" : undefined, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { config } from "./config.js"; 4 | import { logger } from "./logger.js"; 5 | import { MarkdownRulesServer } from "./doc-server.js"; 6 | import { DocIndexService } from "./doc-index.service.js"; 7 | import { FileSystemService } from "./file-system.service.js"; 8 | import { DocParserService } from "./doc-parser.service.js"; 9 | import { LinkExtractorService } from "./link-extractor.service.js"; 10 | import { DocContextService } from "./doc-context.service.js"; 11 | import { DocFormatterService } from "./doc-formatter.service.js"; 12 | 13 | /** 14 | * Entry point for the Markdown Rules MCP Server 15 | */ 16 | (async () => { 17 | try { 18 | const fileSystem = new FileSystemService(config); 19 | const docParser = new DocParserService(); 20 | const linkExtractor = new LinkExtractorService(fileSystem); 21 | const docIndex = new DocIndexService(config, fileSystem, docParser, linkExtractor); 22 | const docFormatter = new DocFormatterService(docIndex, fileSystem); 23 | const docContextService = new DocContextService(config, docIndex, docFormatter); 24 | const server = new MarkdownRulesServer(fileSystem, docIndex, docContextService); 25 | await server.run(); 26 | } catch (error) { 27 | logger.error(`Fatal server error: ${error instanceof Error ? error.message : String(error)}`); 28 | process.exit(1); 29 | } 30 | })(); 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Markdown Rules MCP 2 | 3 | ## Development Requirements 4 | 5 | ### Setup 6 | 7 | 1. Install Node.js 20+ for development: 8 | ```bash 9 | nvm use # Uses Node 20 from .nvmrc 10 | ``` 11 | 12 | 2. Install dependencies: 13 | ```bash 14 | npm install 15 | ``` 16 | 17 | 3. Build the project: 18 | ```bash 19 | npm run build 20 | ``` 21 | 22 | 4. Run tests: 23 | ```bash 24 | npm test 25 | ``` 26 | 27 | 5. Run the inspector: 28 | ```bash 29 | npm run inspector 30 | ``` 31 | 32 | ### Cursor & Running the MCP Server Locally (WSL) 33 | 34 | Here at Valstro, we're using Windows Subsystem for Linux (WSL) to run our MCP server. Cursor runs in Powershell & uses the WSL extension. 35 | 36 | Therefore, to test the MCP server locally, we have a local `.cursor/mcp.json` file that points to a bash script that runs the MCP server with this setup. 37 | 38 | If you're using WSL, you can use the `wsl-start-server.sh` script to start the MCP server. Just run this first: 39 | 40 | ```bash 41 | chmod +x wsl-start-server.sh 42 | ``` 43 | 44 | Then you can run the MCP server in Cursor Settings > MCP Servers > markdown-rules-wsl > Run Server. 45 | 46 | ### Running the MCP Server Locally using other methods 47 | 48 | First, disable the local MCP server in Cursor Settings > MCP Servers > markdown-rules-wsl > Disable. 49 | 50 | Now follow the instructions in the [README](README.md) to run the MCP server locally. -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: merge-main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | packages: write 12 | 13 | jobs: 14 | merge-main: 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 15 17 | 18 | steps: 19 | - name: Clone Repo 20 | uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - name: Setup Node 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version-file: .nvmrc 28 | registry-url: "https://registry.npmjs.org" 29 | cache: npm 30 | 31 | - name: Verify npm authentication 32 | if: ${{ steps.release.outputs.releases_created }} 33 | run: npm whoami 34 | env: 35 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 36 | 37 | - name: Install Dependencies 38 | run: npm ci 39 | 40 | - name: Run Tests 41 | run: npm test 42 | 43 | - name: Creating Release 44 | id: release 45 | uses: google-github-actions/release-please-action@v3 46 | with: 47 | command: manifest 48 | 49 | - name: Build Package 50 | if: ${{ steps.release.outputs.releases_created }} 51 | run: npm run build 52 | 53 | - name: Publish Package (NPM Registry) 54 | if: ${{ steps.release.outputs.releases_created }} 55 | run: npm publish --access public 56 | env: 57 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | startCommand: 2 | type: stdio 3 | configSchema: 4 | # JSON Schema defining the configuration options for the MCP. 5 | type: object 6 | required: 7 | - projectRoot 8 | properties: 9 | projectRoot: 10 | type: string 11 | description: The absolute path to the project root. 12 | markdownInclude: 13 | type: string 14 | description: The glob pattern or patterns to include in the doc index. 15 | default: "**/*.md" 16 | markdownExclude: 17 | type: string 18 | description: The glob pattern or patterns to exclude from the doc index. 19 | default: "**/node_modules/**,**/build/**,**/dist/**,**/.git/**,**/coverage/**,**/.next/**,**/.nuxt/**,**/out/**,**/.cache/**,**/tmp/**,**/temp/**" 20 | hoistContext: 21 | type: boolean 22 | description: Whether to hoist the related / linked docs to the top of the context window. 23 | default: true 24 | commandFunction: | 25 | (config) => ({ 26 | command: 'node', 27 | args: ['build/index.js'], 28 | env: { 29 | MARKDOWN_INCLUDE: config.markdownInclude || "", 30 | MARKDOWN_EXCLUDE: config.markdownExclude || "", 31 | HOIST_CONTEXT: config.hoistContext ? "true" : "false", 32 | PROJECT_ROOT: config.projectRoot 33 | } 34 | }) 35 | exampleConfig: 36 | projectRoot: "/Users/jason/Projects/smithery" 37 | markdownInclude: "**/*.md" 38 | markdownExclude: "**/node_modules/**,**/build/**,**/dist/**,**/.git/**,**/coverage/**,**/.next/**,**/.nuxt/**,**/out/**,**/.cache/**,**/tmp/**,**/temp/**" 39 | hoistContext: true -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: pull-request-main 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | branches: [main] 7 | 8 | jobs: 9 | # when in branches, if multiple runs exist and are active, cancel all but most recent run 10 | cancel-previous-runs: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Cancel Previous Runs 14 | uses: styfle/cancel-workflow-action@0.9.1 15 | 16 | pr-main: 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 15 19 | steps: 20 | - name: Checkout PR 21 | uses: actions/checkout@v4 22 | with: 23 | ref: ${{ github.event.pull_request.head.sha }} 24 | # Don't persist credentials 25 | persist-credentials: false 26 | 27 | - name: Setup Node 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version-file: .nvmrc 31 | # Defaults to the user or organization that owns the workflow file 32 | scope: "@valstro" 33 | registry-url: "https://npm.pkg.github.com" 34 | cache: npm 35 | 36 | - name: Install Dependencies (Safe Mode) 37 | run: | 38 | # Install without running scripts for security 39 | npm ci --ignore-scripts 40 | 41 | - name: Run Tests (Trusted Repos Only) 42 | # Only run tests for trusted repos to prevent arbitrary code execution 43 | if: github.event.pull_request.head.repo.full_name == github.repository 44 | run: npm test 45 | 46 | - name: Test Build 47 | run: npm run build 48 | 49 | # Skip if from fork 50 | if: github.event.pull_request.head.repo.full_name == github.repository -------------------------------------------------------------------------------- /docs/project-rules.md: -------------------------------------------------------------------------------- 1 | --- 2 | alwaysApply: true 3 | --- 4 | 5 | # Project Rules 6 | 7 | ## What is this project? 8 | 9 | This project is a Model Context Protocol (MCP) server that provides a portable and enhanced alternative to editor-specific documentation rules (like Cursor Rules). It allows you to define project documentation and context in standard Markdown files, making them usable across any MCP-compatible AI coding tool. 10 | 11 | ## Project structure 12 | 13 | ``` 14 | ├── .cursor/ 15 | ├── .git/ 16 | ├── .vscode/ 17 | ├── build/ 18 | ├── docs/ 19 | ├── node_modules/ 20 | ├── src/ 21 | │ ├── mocks/ 22 | │ ├── tests/ 23 | │ │ └── __mocks__/ 24 | │ │ ├── config.mock.ts 25 | │ │ ├── doc-formatter.service.mock.ts 26 | │ │ ├── doc-index.service.mock.ts 27 | │ │ ├── doc-parser.service.mock.ts 28 | │ │ ├── file-system.service.mock.ts 29 | │ │ └── link-extractor.service.mock.ts 30 | │ │ ├── doc-context.service.integration.test.ts 31 | │ │ ├── doc-formatter.service.test.ts 32 | │ │ ├── doc-index.service.test.ts 33 | │ │ ├── doc-parser.service.test.ts 34 | │ │ └── link-extractor.service.test.ts 35 | │ ├── config.ts 36 | │ ├── doc-context.service.ts 37 | │ ├── doc-formatter.service.ts 38 | │ ├── doc-index.service.ts 39 | │ ├── doc-parser.service.ts 40 | │ ├── doc-server.ts 41 | │ ├── file-system.service.ts 42 | │ ├── index.ts 43 | │ ├── link-extractor.service.ts 44 | │ ├── logger.ts 45 | │ ├── types.ts 46 | │ └── util.ts 47 | ├── .gitignore 48 | ├── .npmignore 49 | ├── .prettierrc 50 | ├── Dockerfile 51 | ├── LICENSE 52 | ├── README.md 53 | ├── package-lock.json 54 | ├── package.json 55 | ├── setup.tests.ts 56 | ├── smithery.yaml 57 | ├── tsconfig.json 58 | ├── tsconfig.test.json 59 | ├── vitest.config.ts 60 | ├── wsl-start-server.sh 61 | ``` 62 | 63 | ## package.json 64 | 65 | ### Scripts 66 | 67 | [package.json scripts](../package.json?md-embed=29-37) 68 | 69 | ### Dependencies 70 | 71 | [package.json dependencies](../package.json?md-embed=38-54) 72 | 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@valstro/markdown-rules-mcp", 3 | "version": "0.4.6", 4 | "description": "MCP server that provides a portable and enhanced alternative to editor-specific documentation rules (like Cursor Rules). It allows you to define project documentation and context in standard Markdown files, making them usable across any MCP-compatible AI coding tool.", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/valstro/markdown-rules-mcp.git" 9 | }, 10 | "bin": { 11 | "markdown-rules-mcp": "build/index.js" 12 | }, 13 | "files": [ 14 | "build" 15 | ], 16 | "keywords": [ 17 | "mcp", 18 | "model context protocol", 19 | "markdown", 20 | "rules", 21 | "documentation", 22 | "docs", 23 | "cursor", 24 | "windsurf", 25 | "copilot", 26 | "ai" 27 | ], 28 | "author": "Danny @ Valstro", 29 | "scripts": { 30 | "test": "vitest", 31 | "test:ui": "vitest --ui", 32 | "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", 33 | "prepare": "husky", 34 | "watch": "tsc --watch", 35 | "inspector": "npm run build && LOG_LEVEL=debug MARKDOWN_INCLUDE=./docs/**/*.md npx @modelcontextprotocol/inspector node build/index.js", 36 | "prepublishOnly": "npm run build", 37 | "release-dry-run": "release-please release-pr --dry-run --repo-url=valstro/markdown-rules-mcp --target-branch=main" 38 | }, 39 | "dependencies": { 40 | "@modelcontextprotocol/sdk": "^1.12.0", 41 | "dotenv": "^16.4.5", 42 | "glob": "^11.0.2", 43 | "gray-matter": "^4.0.3", 44 | "micromatch": "^4.0.8", 45 | "zod": "^3.22.4" 46 | }, 47 | "devDependencies": { 48 | "@commitlint/cli": "^19.8.0", 49 | "@commitlint/config-conventional": "^19.8.0", 50 | "@types/js-yaml": "^4.0.9", 51 | "@types/micromatch": "^4.0.9", 52 | "@types/node": "^20.11.24", 53 | "@types/yargs": "^17.0.33", 54 | "husky": "^9.1.7", 55 | "prettier": "^3.4.2", 56 | "release-please": "^16.3.1", 57 | "typescript": "^5.3.3", 58 | "vitest": "^3.1.2" 59 | }, 60 | "engines": { 61 | "node": ">=18.0.0" 62 | }, 63 | "publishConfig": { 64 | "access": "public" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { z } from "zod"; 3 | 4 | dotenv.config(); 5 | 6 | const DEFAULT_MARKDOWN_INCLUDE = "**/*.md"; 7 | const DEFAULT_MARKDOWN_EXCLUDE = 8 | "**/node_modules/**,**/build/**,**/dist/**,**/.git/**,**/coverage/**,**/.next/**,**/.nuxt/**,**/out/**,**/.cache/**,**/tmp/**,**/temp/**"; 9 | const DEFAULT_LOG_LEVEL = "info" as const; 10 | const DEFAULT_PROJECT_ROOT = process.cwd(); 11 | const DEFAULT_HOIST_CONTEXT = true; 12 | 13 | const preprocessMarkdownPattern = (value: string, defaultValue: string) => { 14 | if (!value) return defaultValue; 15 | return value; 16 | }; 17 | 18 | const preprocessLogLevel = (value: string) => { 19 | if (!value) return DEFAULT_LOG_LEVEL; 20 | return value; 21 | }; 22 | 23 | const preprocessProjectRoot = (value: string) => { 24 | if (!value) return DEFAULT_PROJECT_ROOT; 25 | return value; 26 | }; 27 | 28 | const preprocessHoistContext = (value: string | boolean) => { 29 | if (value === undefined || value === null) return DEFAULT_HOIST_CONTEXT; 30 | if (typeof value === "boolean") return value; 31 | if (typeof value === "string") { 32 | const lowerValue = value.toLowerCase(); 33 | if (lowerValue === "true" || lowerValue === "1") return true; 34 | if (lowerValue === "false" || lowerValue === "0") return false; 35 | } 36 | return DEFAULT_HOIST_CONTEXT; 37 | }; 38 | 39 | const configSchema = z.object({ 40 | MARKDOWN_INCLUDE: z 41 | .string() 42 | .transform((val) => preprocessMarkdownPattern(val, DEFAULT_MARKDOWN_INCLUDE)) 43 | .default(DEFAULT_MARKDOWN_INCLUDE), 44 | MARKDOWN_EXCLUDE: z 45 | .string() 46 | .transform((val) => preprocessMarkdownPattern(val, DEFAULT_MARKDOWN_EXCLUDE)) 47 | .default(DEFAULT_MARKDOWN_EXCLUDE), 48 | LOG_LEVEL: z 49 | .enum(["silent", "debug", "info", "warn", "error"]) 50 | .transform(preprocessLogLevel) 51 | .default(DEFAULT_LOG_LEVEL), 52 | HOIST_CONTEXT: z 53 | .union([z.string(), z.boolean()]) 54 | .transform(preprocessHoistContext) 55 | .default(DEFAULT_HOIST_CONTEXT), 56 | USAGE_INSTRUCTIONS_PATH: z.string().optional(), 57 | PROJECT_ROOT: z.string().transform(preprocessProjectRoot).default(DEFAULT_PROJECT_ROOT), 58 | }); 59 | 60 | export type Config = z.infer; 61 | 62 | try { 63 | console.error("Starting, about to parse config"); 64 | configSchema.parse(process.env); 65 | } catch (error) { 66 | const errorMessage = error instanceof Error ? error.message : "Unknown error"; 67 | console.error(`Error parsing config: ${errorMessage}`); 68 | process.exit(1); 69 | } 70 | 71 | export const config = configSchema.parse(process.env); 72 | console.error("Parsed config", config); 73 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface DocMeta { 2 | description: string | undefined; 3 | globs: string[]; 4 | alwaysApply: boolean; 5 | } 6 | 7 | export interface DocLinkRange { 8 | from: number; 9 | to: number | "end"; 10 | } 11 | 12 | export interface DocLink { 13 | anchorText: string; 14 | filePath: string; 15 | rawLinkTarget: string; 16 | isInline: boolean; 17 | inlineLinesRange?: DocLinkRange; 18 | } 19 | 20 | export interface Doc { 21 | contentLinesBeforeParsed: number; 22 | content: string; 23 | meta: DocMeta; 24 | filePath: string; // Absolute path to the file 25 | linksTo: DocLink[]; 26 | isMarkdown: boolean; 27 | isError: boolean; 28 | errorReason?: string; 29 | } 30 | 31 | export type DocIndex = Map; 32 | 33 | export interface IFileSystemService { 34 | findFiles(): Promise; 35 | readFile(path: string): Promise; 36 | resolvePath(...paths: string[]): string; 37 | getDirname(relativeOrAbsolutePath: string): string; 38 | getProjectRoot(): string; 39 | pathExists(path: string): Promise; 40 | getRelativePath(absolutePath: string): string; 41 | } 42 | 43 | export type DocOverride = Partial>; 44 | export interface IDocParserService { 45 | parse(fileName: string, fileContent: string): Doc; 46 | getBlankDoc(fileName: string, docOverride?: DocOverride): Doc; 47 | isMarkdown(fileName: string): boolean; 48 | } 49 | 50 | export interface ILinkExtractorService { 51 | extractLinks(docFilePath: string, docContent: string): DocLink[]; 52 | } 53 | 54 | export type DocIndexType = Exclude; 55 | 56 | export interface IDocIndexService { 57 | buildIndex(): Promise; 58 | loadInitialDocs(): Promise>; 59 | recursivelyResolveAndLoadLinks(initialPathsToProcess: Set): Promise; 60 | getDoc(absoluteFilePath: string): Promise; 61 | getDocs(absoluteFilePaths: string[]): Promise; 62 | getAgentAttachableDocs(): Doc[]; 63 | getDocsByType(type: DocIndexType): Doc[]; 64 | getDocMap(): DocIndex; 65 | docs: Doc[]; 66 | } 67 | 68 | export type AttachedItemFileType = "doc" | "file"; 69 | export type AttachedItemType = "auto" | "agent" | "always" | "related" | "manual"; 70 | export interface AttachedItem { 71 | filePath: string; 72 | description?: string; 73 | content?: string; 74 | fileType: AttachedItemFileType; 75 | type: AttachedItemType; 76 | } 77 | 78 | export interface ContextItem { 79 | doc: Doc; 80 | type: AttachedItemType; 81 | linkedViaAnchor?: string; 82 | linkedFromPath?: string; // Path of the item that first linked to this one (if type is 'related') 83 | } 84 | 85 | export interface IDocContextService { 86 | buildContextItems( 87 | attachedFiles: string[], 88 | relevantDocsByDescription: string[] 89 | ): Promise; 90 | buildContextOutput(attachedFiles: string[], relevantDocsByDescription: string[]): Promise; 91 | } 92 | 93 | export interface IDocFormatterService { 94 | formatContextOutput(items: ContextItem[]): Promise; 95 | formatDoc(item: ContextItem): Promise; 96 | } 97 | -------------------------------------------------------------------------------- /src/file-system.service.ts: -------------------------------------------------------------------------------- 1 | import { glob } from "glob"; 2 | import fs from "fs/promises"; 3 | import path from "path"; 4 | import { Config } from "./config.js"; 5 | import { IFileSystemService } from "./types.js"; 6 | 7 | /** 8 | * Manages the file system operations. 9 | * 10 | * @remarks 11 | * This service is responsible for finding all markdown files in the project based on the glob pattern, 12 | * and reading the files and returning their content. 13 | * It also resolves relative paths to absolute paths among other path related operations. 14 | * 15 | * @example 16 | * ```typescript 17 | * const fileSystem = new FileSystemService(config); 18 | * const files = await fileSystem.findFiles(); 19 | * ``` 20 | */ 21 | export class FileSystemService implements IFileSystemService { 22 | constructor(private config: Config) {} 23 | 24 | /** 25 | * Find all files matching the glob pattern in the project root 26 | * @returns absolute paths to all files matching the pattern 27 | */ 28 | findFiles(): Promise { 29 | const includes = this.config.MARKDOWN_INCLUDE.split(",") 30 | .map((pattern) => pattern.trim()) 31 | .filter(Boolean); 32 | 33 | const excludes = this.config.MARKDOWN_EXCLUDE.split(",") 34 | .map((pattern) => pattern.trim()) 35 | .filter(Boolean); 36 | 37 | return glob(includes, { 38 | cwd: this.config.PROJECT_ROOT, 39 | absolute: true, 40 | ignore: excludes, 41 | }); 42 | } 43 | 44 | /** 45 | * Read a file and return its content 46 | * @param path - The path to the file to read 47 | * @returns The content of the file 48 | */ 49 | readFile(path: string): Promise { 50 | return fs.readFile(path, "utf-8"); 51 | } 52 | 53 | /** 54 | * Resolve a relative or absolute path to an absolute path 55 | * @param relativeOrAbsolutePath - The path to resolve 56 | * @returns The absolute path 57 | */ 58 | resolvePath(...paths: string[]): string { 59 | return path.resolve(...paths); 60 | } 61 | 62 | /** 63 | * Check if a path exists (file or directory). 64 | * @param path - The path to check. 65 | * @returns True if the path exists, false otherwise. 66 | */ 67 | async pathExists(path: string): Promise { 68 | try { 69 | await fs.access(path); 70 | return true; 71 | } catch (error: any) { 72 | return false; 73 | } 74 | } 75 | 76 | /** 77 | * Get the directory name of a file path 78 | * @param relativeOrAbsolutePath - The path to get the directory name of 79 | * @returns The absolute directory name of the file path 80 | */ 81 | getDirname(relativeOrAbsolutePath: string): string { 82 | return path.dirname(relativeOrAbsolutePath); 83 | } 84 | 85 | /** 86 | * Get the project root 87 | * @returns The project root 88 | */ 89 | getProjectRoot(): string { 90 | return this.config.PROJECT_ROOT; 91 | } 92 | 93 | /** 94 | * Get the relative path of an absolute path 95 | * @param absolutePath - The absolute path to get the relative path of 96 | * @returns The relative path of the absolute path 97 | */ 98 | getRelativePath(absolutePath: string): string { 99 | return path.relative(this.getProjectRoot(), absolutePath); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/doc-parser.service.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./logger.js"; 2 | import { z } from "zod"; 3 | import matter from "gray-matter"; 4 | import { getErrorMsg } from "./util.js"; 5 | import { Doc, DocOverride, IDocParserService } from "./types.js"; 6 | 7 | const docMetaSchema = z.object({ 8 | description: z.string().optional().nullable(), 9 | globs: z 10 | .union([z.array(z.string()), z.string()]) 11 | .optional() 12 | .nullable(), 13 | alwaysApply: z.boolean().optional().nullable(), 14 | }); 15 | 16 | /** 17 | * Parses a markdown file and returns a Doc object. 18 | * 19 | * @remarks 20 | * This service is responsible for parsing a markdown file and returning a Doc object. 21 | * It uses the gray-matter library to parse the markdown file. 22 | * 23 | * @example 24 | * ```typescript 25 | * const docParser = new DocParserService(); 26 | * const doc = docParser.parse(fileName, fileContent); 27 | * ``` 28 | */ 29 | export class DocParserService implements IDocParserService { 30 | parse(fileName: string, fileContent: string): Doc { 31 | const doc: Doc = this.getBlankDoc(fileName, { content: fileContent }); 32 | 33 | try { 34 | const matterResult = matter(fileContent); 35 | const meta = docMetaSchema.parse(matterResult.data); 36 | doc.meta = { 37 | description: this.parseDescription(meta.description), 38 | globs: this.parseGlobs(meta.globs), 39 | alwaysApply: this.parseAlwaysApply(meta.alwaysApply), 40 | }; 41 | doc.content = this.trimContent(matterResult.content); 42 | return doc; 43 | } catch (error) { 44 | logger.error(`Error parsing doc: ${fileName} ${getErrorMsg(error)}`); 45 | doc.isError = true; 46 | doc.errorReason = `Failed to parse doc meta YAML: ${getErrorMsg(error)}`; 47 | return doc; 48 | } 49 | } 50 | 51 | getBlankDoc(fileName: string, docOverride?: DocOverride): Doc { 52 | return { 53 | contentLinesBeforeParsed: this.countLines(docOverride?.content ?? ""), 54 | content: this.trimContent(docOverride?.content ?? ""), 55 | meta: { 56 | description: undefined, 57 | globs: [], 58 | alwaysApply: false, 59 | }, 60 | filePath: fileName, 61 | linksTo: [], 62 | isMarkdown: this.isMarkdown(fileName), 63 | isError: docOverride?.isError ?? false, 64 | errorReason: docOverride?.errorReason, 65 | }; 66 | } 67 | 68 | countLines(content: string): number { 69 | return content.split("\n").length; 70 | } 71 | 72 | parseGlobs(globs: string | string[] | null | undefined): string[] { 73 | if (Array.isArray(globs)) { 74 | return globs; 75 | } 76 | 77 | if (typeof globs === "string") { 78 | const globsArray = globs.replace(/\s+/g, "").split(","); 79 | return globsArray.map((glob) => glob.trim()); 80 | } 81 | 82 | return []; 83 | } 84 | 85 | parseAlwaysApply(alwaysApply: string | boolean | null | undefined): boolean { 86 | if (typeof alwaysApply === "boolean") { 87 | return alwaysApply; 88 | } 89 | 90 | if (typeof alwaysApply === "string") { 91 | return alwaysApply.toLowerCase() === "true"; 92 | } 93 | 94 | return false; 95 | } 96 | 97 | parseDescription(description: string | null | undefined): string | undefined { 98 | return typeof description === "string" ? description.trim() : undefined; 99 | } 100 | 101 | isMarkdown(fileName: string): boolean { 102 | return fileName.toLowerCase().endsWith(".md"); 103 | } 104 | 105 | trimContent(content: string): string { 106 | // Trim leading/trailing whitespace characters 107 | let result = content.trim(); 108 | // Replace multiple consecutive newlines with a single newline 109 | result = result.replace(/\n{2,}/g, "\n"); 110 | return result; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/doc-formatter.service.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./logger.js"; 2 | import { 3 | AttachedItemFileType, 4 | ContextItem, 5 | DocLinkRange, 6 | IDocFormatterService, 7 | IDocIndexService, 8 | IFileSystemService, 9 | } from "./types.js"; 10 | import { getErrorMsg } from "./util.js"; 11 | 12 | export class DocFormatterService implements IDocFormatterService { 13 | constructor( 14 | private docIndexService: IDocIndexService, 15 | private fileSystem: IFileSystemService 16 | ) {} 17 | 18 | async formatDoc(item: ContextItem): Promise { 19 | const { doc, type, linkedViaAnchor } = item; 20 | const fileType: AttachedItemFileType = doc.isMarkdown ? "doc" : "file"; 21 | 22 | const inlineDocMap = new Map(); 23 | if (doc.isMarkdown && !doc.isError) { 24 | const inlineLinks = doc.linksTo.filter((link) => link.isInline); 25 | await Promise.all( 26 | inlineLinks.map(async (link) => { 27 | try { 28 | const inlineDoc = await this.docIndexService.getDoc(link.filePath); 29 | if (inlineDoc.isError) { 30 | logger.warn( 31 | `Skipping inline expansion for error doc: ${link.filePath} in ${doc.filePath}` 32 | ); 33 | return; 34 | } 35 | let inlineContent = inlineDoc.content ?? ""; 36 | inlineContent = this.extractRangeContent(inlineContent, link.inlineLinesRange); 37 | const rangeAttr = link.inlineLinesRange 38 | ? ` lines="${this.formatRange(link.inlineLinesRange)}"` 39 | : ""; 40 | const escapedDescription = link.anchorText?.replace(/"/g, """) ?? ""; 41 | const inlineTag = `\n${inlineContent}\n`; 42 | 43 | const key = link.filePath + "||" + link.rawLinkTarget; 44 | inlineDocMap.set(key, inlineTag); 45 | } catch (error) { 46 | logger.error( 47 | `Failed to fetch or format inline doc ${link.filePath} referenced in ${doc.filePath}: ${getErrorMsg(error)}` 48 | ); 49 | } 50 | }) 51 | ); 52 | } 53 | 54 | let processedContent = ""; 55 | let lastIndex = 0; 56 | const linkRegex = /\[([^\]]+?)\]\(([^)]+)\)/g; 57 | let match: RegExpExecArray | null; 58 | const sourceDir = this.fileSystem.getDirname(doc.filePath); 59 | const contentToProcess = doc.content ?? ""; 60 | 61 | while ((match = linkRegex.exec(contentToProcess)) !== null) { 62 | processedContent += contentToProcess.substring(lastIndex, match.index); 63 | processedContent += match[0]; 64 | lastIndex = linkRegex.lastIndex; 65 | 66 | const linkTarget = match[2]; 67 | const [relativePath] = linkTarget.split("?"); 68 | const cleanedRelativePath = relativePath.replace(/&/g, "&"); 69 | 70 | try { 71 | const absolutePath = this.fileSystem.resolvePath(sourceDir, cleanedRelativePath); 72 | const lookupKey = absolutePath + "||" + linkTarget; 73 | 74 | if (inlineDocMap.has(lookupKey)) { 75 | processedContent += "\n" + inlineDocMap.get(lookupKey); 76 | } 77 | } catch (error) { 78 | logger.warn( 79 | `Could not process link target "${linkTarget}" in ${doc.filePath}: ${getErrorMsg(error)}` 80 | ); 81 | } 82 | } 83 | processedContent += contentToProcess.substring(lastIndex); 84 | 85 | const trimmedProcessedContent = processedContent.replace(/^\s*\n+|\n+\s*$/g, ""); 86 | 87 | const descriptionSource = 88 | type === "related" ? (linkedViaAnchor ?? doc.meta.description) : doc.meta.description; 89 | 90 | const escapedDescription = descriptionSource?.replace(/"/g, """); 91 | const descAttr = escapedDescription ? ` description="${escapedDescription}"` : ""; 92 | 93 | if (fileType === "doc") { 94 | return `\n${trimmedProcessedContent}\n`; 95 | } else { 96 | return `\n${trimmedProcessedContent}\n`; 97 | } 98 | } 99 | 100 | async formatContextOutput(items: ContextItem[]): Promise { 101 | const formattedDocs = await Promise.all(items.map((item) => this.formatDoc(item))); 102 | return formattedDocs.join("\n\n"); 103 | } 104 | 105 | private extractRangeContent(content: string, range?: DocLinkRange): string { 106 | if (!range) { 107 | return content; 108 | } 109 | const lines = content.split("\n"); 110 | const startLine = Math.max(0, range.from); 111 | const endLine = 112 | range.to === "end" 113 | ? lines.length 114 | : typeof range.to === "number" 115 | ? Math.min(lines.length, range.to + 1) 116 | : lines.length; 117 | 118 | if (startLine >= endLine) { 119 | logger.warn(`Invalid range ${this.formatRange(range)}: start >= end. Returning empty.`); 120 | return ""; 121 | } 122 | 123 | return lines.slice(startLine, endLine).join("\n"); 124 | } 125 | 126 | private formatRange(range: DocLinkRange): string { 127 | // Convert 0-based indices back to 1-based for display 128 | const displayFrom = range.from + 1; 129 | const displayTo = range.to === "end" ? "end" : range.to + 1; 130 | return `${displayFrom}-${displayTo}`; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.4.6](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.4.5...markdown-rules-mcp-v0.4.6) (2025-06-12) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * **agent-docs:** re-index agent docs tool & simplify tool description ([#51](https://github.com/valstro/markdown-rules-mcp/issues/51)) ([ce16132](https://github.com/valstro/markdown-rules-mcp/commit/ce161322d3cd5bf041c8625ff847d75339075fac)) 9 | 10 | ## [0.4.5](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.4.4...markdown-rules-mcp-v0.4.5) (2025-06-03) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * **docs:** updating docs in line with working smithery cli setup ([#49](https://github.com/valstro/markdown-rules-mcp/issues/49)) ([3a43f15](https://github.com/valstro/markdown-rules-mcp/commit/3a43f15d014ac7636cfb50e5e6b8edb82f93737c)) 16 | 17 | ## [0.4.4](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.4.3...markdown-rules-mcp-v0.4.4) (2025-05-29) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * **smithery:** syntax issue ([#46](https://github.com/valstro/markdown-rules-mcp/issues/46)) ([1ca7da0](https://github.com/valstro/markdown-rules-mcp/commit/1ca7da0e2b14aaa0788dea476507257e86664720)) 23 | 24 | ## [0.4.3](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.4.2...markdown-rules-mcp-v0.4.3) (2025-05-29) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * **tools:** more debugging info in list indexed docs ([#44](https://github.com/valstro/markdown-rules-mcp/issues/44)) ([b0ef17c](https://github.com/valstro/markdown-rules-mcp/commit/b0ef17c1225f5c3131d976d94999cf5955404bef)) 30 | 31 | ## [0.4.2](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.4.1...markdown-rules-mcp-v0.4.2) (2025-05-29) 32 | 33 | 34 | ### Bug Fixes 35 | 36 | * **smithery:** adding smithery config forwarding ([#42](https://github.com/valstro/markdown-rules-mcp/issues/42)) ([dce4cf0](https://github.com/valstro/markdown-rules-mcp/commit/dce4cf0db59f9bcb8e58b398f12107bbcf6bfa5c)) 37 | 38 | ## [0.4.1](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.4.0...markdown-rules-mcp-v0.4.1) (2025-05-29) 39 | 40 | 41 | ### Bug Fixes 42 | 43 | * **tool:** quotes in tool call ([#40](https://github.com/valstro/markdown-rules-mcp/issues/40)) ([da9f8e2](https://github.com/valstro/markdown-rules-mcp/commit/da9f8e2fe31a46fab6e87304f4b72551f6bb13f3)) 44 | 45 | ## [0.4.0](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.3.0...markdown-rules-mcp-v0.4.0) (2025-05-29) 46 | 47 | 48 | ### Features 49 | 50 | * **tools:** debugging tools & config parsing fix ([#35](https://github.com/valstro/markdown-rules-mcp/issues/35)) ([632b401](https://github.com/valstro/markdown-rules-mcp/commit/632b40175f2f2fba2525c3043a47fcf5eacbf776)) 51 | 52 | ## [0.3.0](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.2.5...markdown-rules-mcp-v0.3.0) (2025-05-28) 53 | 54 | 55 | ### Features 56 | 57 | * **config:** can exclude files from includes ([#29](https://github.com/valstro/markdown-rules-mcp/issues/29)) ([f734c99](https://github.com/valstro/markdown-rules-mcp/commit/f734c99b4d5ef4125831f1b8adea4e3cba8f1060)) 58 | 59 | ## [0.2.5](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.2.4...markdown-rules-mcp-v0.2.5) (2025-05-27) 60 | 61 | 62 | ### Bug Fixes 63 | 64 | * **npm:** registry location ([#27](https://github.com/valstro/markdown-rules-mcp/issues/27)) ([843b5c7](https://github.com/valstro/markdown-rules-mcp/commit/843b5c7348336556e77f148242f60ca67e6aab2d)) 65 | 66 | ## [0.2.4](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.2.3...markdown-rules-mcp-v0.2.4) (2025-05-27) 67 | 68 | 69 | ### Bug Fixes 70 | 71 | * **package:** removing github package step ([#25](https://github.com/valstro/markdown-rules-mcp/issues/25)) ([b87ffb1](https://github.com/valstro/markdown-rules-mcp/commit/b87ffb12361a4d0a833670e4289f2201bfddb0d1)) 72 | 73 | ## [0.2.3](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.2.2...markdown-rules-mcp-v0.2.3) (2025-05-27) 74 | 75 | 76 | ### Bug Fixes 77 | 78 | * **package:** preparing actions, docs & code for public usage ([#23](https://github.com/valstro/markdown-rules-mcp/issues/23)) ([c99113a](https://github.com/valstro/markdown-rules-mcp/commit/c99113addc97ed6541a5470c715d605d83ffc298)) 79 | 80 | ## [0.2.2](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.2.1...markdown-rules-mcp-v0.2.2) (2025-05-07) 81 | 82 | 83 | ### Bug Fixes 84 | 85 | * improve agent included docs always being included ([#21](https://github.com/valstro/markdown-rules-mcp/issues/21)) ([93d9738](https://github.com/valstro/markdown-rules-mcp/commit/93d973810a18dfdf2e90a42382794329413ce4b3)) 86 | 87 | ## [0.2.1](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.2.0...markdown-rules-mcp-v0.2.1) (2025-05-06) 88 | 89 | 90 | ### Bug Fixes 91 | 92 | * agent docs not being picked up ([#19](https://github.com/valstro/markdown-rules-mcp/issues/19)) ([8d03c0d](https://github.com/valstro/markdown-rules-mcp/commit/8d03c0de43cfb9e1fafa39fb34997fdf6c0c96e8)) 93 | 94 | ## [0.2.0](https://github.com/valstro/markdown-rules-mcp/compare/markdown-rules-mcp-v0.1.0...markdown-rules-mcp-v0.2.0) (2025-05-06) 95 | 96 | 97 | ### Features 98 | 99 | * initial release ([#3](https://github.com/valstro/markdown-rules-mcp/issues/3)) ([6ec690f](https://github.com/valstro/markdown-rules-mcp/commit/6ec690f6af56d18f0e4920779927d1a2fa343858)) 100 | 101 | 102 | ### Bug Fixes 103 | 104 | * adding config test step ([#17](https://github.com/valstro/markdown-rules-mcp/issues/17)) ([d2e0015](https://github.com/valstro/markdown-rules-mcp/commit/d2e0015625e9e5d54f78df04dd96869a2f6efabe)) 105 | * initial release ([#5](https://github.com/valstro/markdown-rules-mcp/issues/5)) ([693ddc0](https://github.com/valstro/markdown-rules-mcp/commit/693ddc0e609c3681e1ebeceb8df6d24babcaa35e)) 106 | * package issues ([#9](https://github.com/valstro/markdown-rules-mcp/issues/9)) ([c328b3f](https://github.com/valstro/markdown-rules-mcp/commit/c328b3f6e55f3f088342060b767ba46c3c92f569)) 107 | * package name ([#7](https://github.com/valstro/markdown-rules-mcp/issues/7)) ([b157068](https://github.com/valstro/markdown-rules-mcp/commit/b157068619e2280ce0790886afdd9a6731cd6c9b)) 108 | * package token for publishing ([#15](https://github.com/valstro/markdown-rules-mcp/issues/15)) ([52a5fc9](https://github.com/valstro/markdown-rules-mcp/commit/52a5fc98dcb801240dff69bfda3e5fd6bc3d16cc)) 109 | * private package ([#13](https://github.com/valstro/markdown-rules-mcp/issues/13)) ([687297b](https://github.com/valstro/markdown-rules-mcp/commit/687297b024d69a5c128d767129d8a97c450d4f09)) 110 | * release please config ([#11](https://github.com/valstro/markdown-rules-mcp/issues/11)) ([c62c553](https://github.com/valstro/markdown-rules-mcp/commit/c62c55380bcb76382d21ec9d413482fbb092e49f)) 111 | -------------------------------------------------------------------------------- /src/link-extractor.service.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./logger.js"; 2 | import { getErrorMsg } from "./util.js"; 3 | import { DocLink, DocLinkRange, IFileSystemService, ILinkExtractorService } from "./types.js"; 4 | import { FileSystemService } from "./file-system.service.js"; 5 | 6 | /** 7 | * Extracts links from a markdown document. 8 | * 9 | * @remarks 10 | * This service is responsible for extracting links from a markdown document. 11 | * It uses a regular expression to find markdown links like [text](path). 12 | * Links intended for processing by this tool must either include the `md-link=true` (or `md-link=1`) 13 | * query parameter or the `md-embed` query parameter (unless `md-embed` is explicitly 'false'). 14 | * A link with `md-embed` is implicitly considered a link to be processed. 15 | * 16 | * Embedding behavior is controlled by the `md-embed` query parameter: 17 | * - If `md-embed` is absent or set to `false`, the link is processed (only if `md-link=true` is present) but *not* marked for embedding (`isInline: false`), and no range is parsed. 18 | * - If `md-embed` is present and not `false` (e.g., `md-embed=true`), the link is processed and embedded (`isInline: true`) without a specific line range (`inlineLinesRange: undefined`). 19 | * - If the value of `md-embed` is a range (e.g., `10-20`, `10-`, `-20`, `10-end`), 20 | * the link is processed and only that specific line range of the target document is embedded. 21 | * 22 | * It converts the relative path to an absolute path using the file system service 23 | * and handles potential HTML entities like & before parsing. 24 | * 25 | * @example 26 | * ```typescript 27 | * const linkExtractor = new LinkExtractorService(fileSystem); 28 | * const links = linkExtractor.extractLinks(docFilePath, docContent); 29 | * // Example link: [Include this](./some/doc.md?md-link=true&md-embed=10-20) // Linked & Embedded (range) 30 | * // Example link: [Include this too](./some/doc.md?md-embed=10-20) // Linked & Embedded (range) 31 | * // Example link: [Include all](./another/doc.md?md-link=true&md-embed=true) // Linked & Embedded (all) 32 | * // Example link: [Include all too](./another/doc.md?md-embed=true) // Linked & Embedded (all) 33 | * // Example link: [Reference only](./ref.md?md-link=true) // Linked, Not Embedded 34 | * // Example link: [Reference only, explicit](./ref.md?md-link=true&md-embed=false) // Linked, Not Embedded 35 | * // Example link: [Ignored](./ref.md?md-embed=false) // Not Linked, Not Embedded 36 | * ``` 37 | */ 38 | export class LinkExtractorService implements ILinkExtractorService { 39 | static readonly LINK_PARAM = "md-link"; 40 | static readonly EMBED_PARAM = "md-embed"; 41 | constructor(private fileSystem: IFileSystemService) {} 42 | 43 | extractLinks(docFilePath: string, docContent: string): DocLink[] { 44 | const linkedDocs: DocLink[] = []; 45 | const linkRegex = /\[([^\]]+?)\]\(([^)]+)\)/g; 46 | let match: RegExpExecArray | null; 47 | const sourceDir = this.fileSystem.getDirname(docFilePath); 48 | 49 | while ((match = linkRegex.exec(docContent)) !== null) { 50 | const anchorText = match[1]; 51 | const linkTarget = match[2]; 52 | const [relativePath] = linkTarget.split("?"); 53 | const cleanedLinkTarget = linkTarget.replace(/&/g, "&"); 54 | const cleanedRelativePath = relativePath.replace(/&/g, "&"); 55 | 56 | try { 57 | const url = new URL(cleanedLinkTarget, "file:///"); 58 | 59 | const shouldProcessLink = 60 | this.isParamTruthy(url, LinkExtractorService.LINK_PARAM) || 61 | this.isEmbedParamPresentAndNotFalse(url); 62 | 63 | if (shouldProcessLink) { 64 | const { isInline, inlineLinesRange } = this.parseEmbedParameter(url, docFilePath); 65 | 66 | const absolutePath = this.fileSystem.resolvePath(sourceDir, cleanedRelativePath); 67 | 68 | logger.debug( 69 | `Found link: Anchor='${anchorText}', Target='${linkTarget}', RelativePath='${cleanedRelativePath}', AbsolutePath='${absolutePath}', SourceDir='${sourceDir}', Embed=${isInline}, Range=${ 70 | inlineLinesRange ? `${inlineLinesRange.from}-${inlineLinesRange.to}` : "N/A" 71 | }` 72 | ); 73 | 74 | linkedDocs.push({ 75 | filePath: absolutePath, 76 | rawLinkTarget: linkTarget, 77 | isInline, 78 | inlineLinesRange, 79 | anchorText, 80 | }); 81 | } 82 | } catch (error: unknown) { 83 | if (error instanceof TypeError && error.message.includes("Invalid URL")) { 84 | logger.warn(`Skipping invalid URL format: ${cleanedLinkTarget} in ${docFilePath}`); 85 | } else { 86 | logger.error( 87 | `Error processing link: ${cleanedLinkTarget} in ${docFilePath}: ${getErrorMsg(error)}` 88 | ); 89 | } 90 | } 91 | } 92 | 93 | return linkedDocs; 94 | } 95 | 96 | /** 97 | * Parses the 'md-embed' query parameter to determine embedding status and line range. 98 | * 99 | * @param url - The URL object containing the query parameters. 100 | * @param docFilePath - The path of the document containing the link (for logging). 101 | * @returns An object with `isInline` (boolean) and `inlineLinesRange` (DocLinkRange | undefined). 102 | * `isInline` is true if `md-embed` is present and not 'false'. 103 | * `inlineLinesRange` is populated if `md-embed`'s value is a valid range format. 104 | */ 105 | private parseEmbedParameter( 106 | url: URL, 107 | docFilePath: string 108 | ): { isInline: boolean; inlineLinesRange: DocLinkRange | undefined } { 109 | const embedParamValue = url.searchParams.get(LinkExtractorService.EMBED_PARAM); 110 | const isInline = this.isEmbedParamPresentAndNotFalse(url); 111 | 112 | if (!isInline || embedParamValue === null) { 113 | return { isInline: false, inlineLinesRange: undefined }; 114 | } 115 | 116 | let inlineLinesRange: DocLinkRange | undefined; 117 | const rangeString = embedParamValue; 118 | 119 | const parts = rangeString.split("-"); 120 | if (parts.length === 2) { 121 | const fromStr = parts[0]; 122 | const toStr = parts[1]; 123 | 124 | // Parse 'from' to 0-based index 125 | let fromIdx = 0; // Default to start of file (index 0) 126 | if (fromStr !== "") { 127 | const parsedFrom = Number(fromStr); 128 | if (!isNaN(parsedFrom) && parsedFrom >= 1) { 129 | fromIdx = parsedFrom - 1; // Convert 1-based to 0-based 130 | } else { 131 | logger.warn( 132 | `Invalid start line "${fromStr}" in 'md-embed' range "${rangeString}" in ${docFilePath}. Using start of file.` 133 | ); 134 | // Keep fromIdx = 0 135 | } 136 | } 137 | 138 | // Parse 'to' to 0-based index or 'end' 139 | let toIdxOrEnd: number | "end" = "end"; // Default to end of file 140 | if (toStr !== "" && toStr.toLowerCase() !== "end") { 141 | const parsedTo = Number(toStr); 142 | if (!isNaN(parsedTo) && parsedTo >= 1) { 143 | toIdxOrEnd = parsedTo - 1; // Convert 1-based to 0-based 144 | } else { 145 | logger.warn( 146 | `Invalid end line "${toStr}" in 'md-embed' range "${rangeString}" in ${docFilePath}. Using end of file.` 147 | ); 148 | // Keep toIdxOrEnd = 'end' 149 | } 150 | } 151 | 152 | // Validate the 0-based range 153 | if ( 154 | toIdxOrEnd !== "end" && 155 | fromIdx > toIdxOrEnd // Compare 0-based indices 156 | ) { 157 | logger.warn( 158 | `Invalid lines range "${rangeString}" in 'md-embed' parameter in ${docFilePath}: start index (${fromIdx}) is greater than end index (${toIdxOrEnd}). Embedding whole file.` 159 | ); 160 | } else { 161 | inlineLinesRange = { 162 | from: fromIdx, 163 | to: toIdxOrEnd, 164 | }; 165 | // Log the parsed 0-based range 166 | logger.debug( 167 | `Parsed 0-based range from 'md-embed="${rangeString}"' in ${docFilePath}: ${inlineLinesRange.from}-${inlineLinesRange.to}` 168 | ); 169 | } 170 | } else { 171 | logger.debug( 172 | `Value "${rangeString}" for 'md-embed' in ${docFilePath} is not a range format. Embedding whole file.` 173 | ); 174 | } 175 | 176 | return { isInline, inlineLinesRange }; 177 | } 178 | 179 | /** 180 | * Checks if a URL query parameter has a truthy value ('true' or '1'). 181 | * Used specifically for the `md-link` parameter. 182 | * 183 | * @param url - The URL object. 184 | * @param param - The name of the query parameter. 185 | * @returns True if the parameter exists and is 'true' or '1', false otherwise. 186 | */ 187 | private isParamTruthy(url: URL, param: string): boolean { 188 | const paramValue = url.searchParams.get(param); 189 | return paramValue !== null && (paramValue === "true" || paramValue === "1"); 190 | } 191 | 192 | /** 193 | * Checks if the 'md-embed' parameter is present and its value is not 'false'. 194 | * 195 | * @param url - The URL object. 196 | * @returns True if 'md-embed' exists and is not 'false' (case-insensitive), false otherwise. 197 | */ 198 | private isEmbedParamPresentAndNotFalse(url: URL): boolean { 199 | const embedParamValue = url.searchParams.get(LinkExtractorService.EMBED_PARAM); 200 | return embedParamValue !== null && embedParamValue.toLowerCase() !== "false"; 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/tests/doc-parser.service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mocked } from "vitest"; 2 | import { DocParserService } from "../doc-parser.service"; 3 | 4 | describe("DocParserService", () => { 5 | let docParserService: DocParserService; 6 | 7 | beforeEach(() => { 8 | vi.resetAllMocks(); 9 | docParserService = new DocParserService(); 10 | }); 11 | describe("isMarkdown", () => { 12 | it('should return true for filenames ending with ".md"', () => { 13 | expect(docParserService.isMarkdown("test.md")).toBe(true); 14 | }); 15 | 16 | it('should return true for filenames ending with ".MD" (case-insensitive)', () => { 17 | expect(docParserService.isMarkdown("TEST.MD")).toBe(true); 18 | }); 19 | 20 | it('should return false for filenames not ending with ".md"', () => { 21 | expect(docParserService.isMarkdown("test.txt")).toBe(false); 22 | }); 23 | 24 | it('should return false for filenames containing ".md" but not at the end', () => { 25 | expect(docParserService.isMarkdown("test.md.backup")).toBe(false); 26 | }); 27 | 28 | it("should return false for filenames without an extension", () => { 29 | expect(docParserService.isMarkdown("test")).toBe(false); 30 | }); 31 | }); 32 | describe("getBlankDoc", () => { 33 | const fileName = "test.md"; 34 | const content = "Some content"; 35 | 36 | it("should return a Doc object with default values", () => { 37 | const doc = docParserService.getBlankDoc(fileName); 38 | expect(doc).toEqual({ 39 | contentLinesBeforeParsed: 1, 40 | content: "", 41 | meta: { 42 | description: undefined, 43 | globs: [], 44 | alwaysApply: false, 45 | }, 46 | filePath: fileName, 47 | linksTo: [], 48 | isMarkdown: true, 49 | isError: false, 50 | }); 51 | }); 52 | 53 | it("should set content if provided", () => { 54 | const doc = docParserService.getBlankDoc(fileName, { content }); 55 | expect(doc.content).toBe(content); 56 | }); 57 | 58 | it("should set isError flag if provided", () => { 59 | const doc = docParserService.getBlankDoc(fileName, { content, isError: true }); 60 | expect(doc.isError).toBe(true); 61 | }); 62 | 63 | it("should correctly determine isMarkdown based on filename", () => { 64 | const mdDoc = docParserService.getBlankDoc("is.md"); 65 | const txtDoc = docParserService.getBlankDoc("is.txt"); 66 | expect(mdDoc.isMarkdown).toBe(true); 67 | expect(txtDoc.isMarkdown).toBe(false); 68 | }); 69 | }); 70 | 71 | describe("parse", () => { 72 | const fileName = "document.md"; 73 | 74 | it("should parse content and valid full front matter correctly", () => { 75 | const fileContent = `--- 76 | description: A test document 77 | globs: ["*.ts", "*.js"] 78 | alwaysApply: true 79 | --- 80 | # Document Title 81 | Content goes here.`; 82 | const doc = docParserService.parse(fileName, fileContent); 83 | expect(doc.contentLinesBeforeParsed).toBe(7); 84 | expect(doc.content.trim()).toBe("# Document Title\nContent goes here."); 85 | expect(doc.meta).toEqual({ 86 | description: "A test document", 87 | globs: ["*.ts", "*.js"], 88 | alwaysApply: true, 89 | }); 90 | expect(doc.filePath).toBe(fileName); 91 | expect(doc.isMarkdown).toBe(true); 92 | expect(doc.isError).toBe(false); 93 | expect(doc.errorReason).toBeUndefined(); 94 | }); 95 | 96 | it("should remove empty newlines from content", () => { 97 | const fileContent = `--- 98 | description: A test document 99 | globs: ["*.ts", "*.js"] 100 | alwaysApply: true 101 | --- 102 | 103 | 104 | # Document Title 105 | 106 | Content goes here. 107 | 108 | `; 109 | const doc = docParserService.parse(fileName, fileContent); 110 | expect(doc.contentLinesBeforeParsed).toBe(12); 111 | expect(doc.content.trim()).toBe("# Document Title\nContent goes here."); 112 | expect(doc.meta).toEqual({ 113 | description: "A test document", 114 | globs: ["*.ts", "*.js"], 115 | alwaysApply: true, 116 | }); 117 | expect(doc.filePath).toBe(fileName); 118 | expect(doc.isMarkdown).toBe(true); 119 | expect(doc.isError).toBe(false); 120 | expect(doc.errorReason).toBeUndefined(); 121 | }); 122 | 123 | it("should parse content and valid minimal front matter (using defaults)", () => { 124 | const fileContent = `--- 125 | description: " Minimal description " 126 | --- 127 | Minimal content.`; 128 | const doc = docParserService.parse(fileName, fileContent); 129 | 130 | expect(doc.content.trim()).toBe("Minimal content."); 131 | expect(doc.contentLinesBeforeParsed).toBe(4); 132 | expect(doc.meta).toEqual({ 133 | description: "Minimal description", 134 | globs: [], // Default behavior when missing 135 | alwaysApply: false, // Default behavior when missing 136 | }); 137 | expect(doc.isError).toBe(false); 138 | expect(doc.errorReason).toBeUndefined(); 139 | }); 140 | 141 | it("should NOT parse content and description with colon without quotes", () => { 142 | const fileContent = `--- 143 | description: Minimal description: with colon 144 | --- 145 | Minimal content.`; 146 | const doc = docParserService.parse(fileName, fileContent); 147 | expect(doc.contentLinesBeforeParsed).toBe(4); 148 | expect(doc.content).toBe(`--- 149 | description: Minimal description: with colon 150 | --- 151 | Minimal content.`); 152 | expect(doc.meta).toEqual({ 153 | description: undefined, 154 | globs: [], 155 | alwaysApply: false, 156 | }); 157 | expect(doc.isError).toBe(true); 158 | expect(doc.errorReason).toBeDefined(); 159 | }); 160 | 161 | it("should handle null values in front matter (using defaults)", () => { 162 | const fileContent = `--- 163 | description: null 164 | globs: null 165 | alwaysApply: null 166 | --- 167 | Content with nulls.`; 168 | const doc = docParserService.parse(fileName, fileContent); 169 | 170 | expect(doc.content.trim()).toBe("Content with nulls."); 171 | expect(doc.meta).toEqual({ 172 | description: undefined, // null becomes undefined 173 | globs: [], // null becomes [] 174 | alwaysApply: false, // null becomes false 175 | }); 176 | expect(doc.isError).toBe(false); 177 | expect(doc.errorReason).toBeUndefined(); 178 | }); 179 | 180 | it("should parse content correctly when no front matter is present", () => { 181 | const fileContent = `# Just Content 182 | No front matter here.`; 183 | const doc = docParserService.parse(fileName, fileContent); 184 | 185 | expect(doc.content.trim()).toBe("# Just Content\nNo front matter here."); 186 | expect(doc.meta).toEqual({ 187 | description: undefined, 188 | globs: [], 189 | alwaysApply: false, 190 | }); // Defaults apply 191 | expect(doc.isError).toBe(false); 192 | expect(doc.errorReason).toBeUndefined(); 193 | }); 194 | 195 | it("should handle single string glob in front matter", () => { 196 | const fileContent = `--- 197 | globs: "*.ts" 198 | --- 199 | Single glob content.`; 200 | const doc = docParserService.parse(fileName, fileContent); 201 | 202 | expect(doc.content.trim()).toBe("Single glob content."); 203 | expect(doc.meta.globs).toEqual(["*.ts"]); 204 | expect(doc.isError).toBe(false); 205 | expect(doc.errorReason).toBeUndefined(); 206 | }); 207 | 208 | it("should handle multiple string globs in front matter", () => { 209 | const fileContent = `--- 210 | globs: "*.ts, *.js" 211 | --- 212 | String content.`; 213 | const doc = docParserService.parse(fileName, fileContent); 214 | 215 | expect(doc.content.trim()).toBe("String content."); 216 | expect(doc.meta.globs).toEqual(["*.ts", "*.js"]); 217 | expect(doc.isError).toBe(false); 218 | expect(doc.errorReason).toBeUndefined(); 219 | }); 220 | 221 | it("should NOT handle glob outside of quotes", () => { 222 | const fileContent = `--- 223 | globs: *.ts 224 | --- 225 | String content.`; 226 | const doc = docParserService.parse(fileName, fileContent); 227 | 228 | expect(doc.content.trim()).toBe(`--- 229 | globs: *.ts 230 | --- 231 | String content.`); 232 | expect(doc.meta.globs).toEqual([]); 233 | expect(doc.isError).toBe(true); 234 | expect(doc.errorReason).toBeDefined(); 235 | }); 236 | 237 | it("should log error and return default doc structure on invalid front matter schema", () => { 238 | // 'alwaysApply' is a number, which is invalid according to the schema 239 | const fileContent = `--- 240 | description: Invalid type test 241 | alwaysApply: 123 242 | --- 243 | Content after invalid FM.`; 244 | const doc = docParserService.parse(fileName, fileContent); 245 | 246 | // Should still contain original content because gray-matter parses it, but meta fails validation 247 | expect(doc.content.trim()).toBe(`--- 248 | description: Invalid type test 249 | alwaysApply: 123 250 | --- 251 | Content after invalid FM.`); 252 | // Meta should be the default empty object due to parsing error 253 | expect(doc.meta).toEqual({ 254 | description: undefined, 255 | globs: [], 256 | alwaysApply: false, 257 | }); 258 | // The doc itself isn't marked as an error, but the parsing failed 259 | expect(doc.isError).toBe(true); 260 | expect(doc.errorReason).toBeDefined(); 261 | }); 262 | 263 | it("should handle malformed front matter syntax (e.g., bad YAML)", () => { 264 | // Gray-matter might handle some errors gracefully, but let's try invalid syntax 265 | const fileContent = `--- 266 | description: Bad YAML 267 | globs: [one, two 268 | --- 269 | Content after bad FM.`; 270 | const doc = docParserService.parse(fileName, fileContent); 271 | expect(doc.content.startsWith("---")).toBe(true); 272 | expect(doc.meta).toEqual({ 273 | description: undefined, 274 | globs: [], 275 | alwaysApply: false, 276 | }); 277 | expect(doc.isError).toBe(true); 278 | expect(doc.errorReason).toBeDefined(); 279 | }); 280 | }); 281 | }); 282 | -------------------------------------------------------------------------------- /src/doc-index.service.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "./logger.js"; 2 | import { Config } from "./config.js"; 3 | import { 4 | AttachedItemType, 5 | Doc, 6 | DocIndex, 7 | DocIndexType, 8 | IDocIndexService, 9 | IDocParserService, 10 | IFileSystemService, 11 | ILinkExtractorService, 12 | } from "./types.js"; 13 | import { getErrorMsg } from "./util.js"; 14 | 15 | /** 16 | * Manages the index of all documents in the project. 17 | * 18 | * @remarks 19 | * This service is responsible for building the index of all documents in the project. 20 | * It uses the file system service to find all markdown files in the project based on the glob pattern, 21 | * and the link extractor service to extract links from the markdown files recursively. 22 | * It also uses the doc parser service to parse the markdown files and add them to the index. 23 | */ 24 | export class DocIndexService implements IDocIndexService { 25 | private docMap: DocIndex = new Map(); 26 | private pendingDocPromises: Map> = new Map(); // Cache for ongoing fetches 27 | 28 | get docs(): Doc[] { 29 | return Array.from(this.docMap.values()); 30 | } 31 | 32 | getDocMap(): DocIndex { 33 | return this.docMap; 34 | } 35 | 36 | constructor( 37 | private config: Config, 38 | private fileSystem: IFileSystemService, 39 | private docParser: IDocParserService, 40 | private linkExtractor: ILinkExtractorService 41 | ) {} 42 | 43 | /** 44 | * Builds the complete document graph. 45 | * 1. Loads initial documents based on the configured glob pattern. 46 | * 2. Recursively discovers and loads all documents linked from the initial set. 47 | */ 48 | async buildIndex(): Promise { 49 | this.docMap.clear(); 50 | this.pendingDocPromises.clear(); // Also clear pending promises on rebuild 51 | 52 | logger.info( 53 | `Building doc index from: ${this.fileSystem.getProjectRoot()} including: ${this.config.MARKDOWN_INCLUDE} and excluding: ${this.config.MARKDOWN_EXCLUDE}` 54 | ); 55 | 56 | const initialPaths = await this.loadInitialDocs(); 57 | logger.info(`Found ${initialPaths.size} initial markdown files.`); 58 | 59 | await this.recursivelyResolveAndLoadLinks(initialPaths); 60 | logger.info(`Index built. Total docs: ${this.docMap.size}.`); 61 | 62 | return this.docMap; 63 | } 64 | 65 | /** 66 | * Finds files matching the glob pattern, loads them, adds them to the docMap, 67 | * and returns their absolute paths. 68 | */ 69 | async loadInitialDocs(): Promise> { 70 | const initialFilePaths = await this.fileSystem.findFiles(); 71 | const initialDocs = await this.getDocs(initialFilePaths); 72 | this.setDocs(initialDocs); 73 | 74 | return new Set(initialDocs.map((doc) => doc.filePath)); 75 | } 76 | 77 | /** 78 | * Recursively processes links starting from a given set of document paths. 79 | * It finds linked documents, loads any new ones, adds them to the docMap, 80 | * and continues until no new documents are found. 81 | */ 82 | async recursivelyResolveAndLoadLinks(initialPathsToProcess: Set): Promise { 83 | let pathsToProcess = new Set(initialPathsToProcess); 84 | const processedPaths = new Set(); // Keep track of paths already processed 85 | 86 | // Process links iteratively until no new documents are discovered 87 | while (pathsToProcess.size > 0) { 88 | const currentBatchPaths = Array.from(pathsToProcess); 89 | pathsToProcess.clear(); // Prepare for the next iteration's findings 90 | 91 | // Add current batch to processed set 92 | currentBatchPaths.forEach((p) => processedPaths.add(p)); 93 | 94 | // Process the current batch of documents in parallel 95 | const discoveryPromises = currentBatchPaths.map(async (filePath) => { 96 | const doc = this.docMap.get(filePath); 97 | if (!doc) { 98 | logger.warn(`Document not found in map during link resolution: ${filePath}`); 99 | return []; // Skip if doc somehow disappeared 100 | } 101 | // Only process non-error markdown files for links 102 | if (!doc.isMarkdown || doc.isError) { 103 | return []; 104 | } 105 | 106 | // Use the links already populated by getDoc when the doc was loaded/parsed 107 | const linkedDocs = doc.linksTo; 108 | 109 | // Identify paths that are not yet in our graph map 110 | const newPathsToLoad = linkedDocs 111 | .filter((link) => !this.docMap.has(link.filePath)) 112 | .map((link) => link.filePath); 113 | 114 | // Identify paths to queue for the *next* processing iteration. 115 | // These are linked paths that haven't been processed *in any previous or the current* iteration. 116 | const newPathsToQueue = linkedDocs 117 | .filter( 118 | (link) => !processedPaths.has(link.filePath) && !pathsToProcess.has(link.filePath) 119 | ) // Check processedPaths *and* the current pathsToProcess buffer 120 | .map((link) => link.filePath); 121 | 122 | if (newPathsToLoad.length > 0) { 123 | // Fetch the newly discovered documents (this also adds them to docMap via getDoc, which extracts links) 124 | await this.getDocs(newPathsToLoad); // Wait for loading before queuing 125 | logger.debug( 126 | `Loaded ${newPathsToLoad.length} new docs linked from ${filePath}: ${newPathsToLoad.join(", ")}` 127 | ); 128 | } 129 | 130 | // Add newly discovered paths (that haven't been processed/queued) to the next processing queue 131 | newPathsToQueue.forEach((p) => pathsToProcess.add(p)); 132 | 133 | return newPathsToQueue; // Return paths added to the queue for this doc 134 | }); 135 | 136 | // Wait for all processing in the current batch 137 | await Promise.all(discoveryPromises); 138 | 139 | // Loop continues if pathsToProcess has new paths added, otherwise exits 140 | logger.debug(`Next link processing batch size: ${pathsToProcess.size}`); 141 | } 142 | logger.info("Finished resolving all links."); 143 | } 144 | 145 | /** 146 | * Gets a single doc or file. Checks the cache first. If not found, reads and 147 | * parses the file. Handles potential read/parse errors. Adds successfully 148 | * read/parsed docs (or error placeholders) to the internal map. 149 | */ 150 | async getDoc(absoluteFilePath: string): Promise { 151 | // Ensure case consistency if needed, assuming paths are already normalized 152 | const normalizedPath = absoluteFilePath; // Add normalization if required by OS/FS 153 | 154 | if (this.docMap.has(normalizedPath)) { 155 | return this.docMap.get(normalizedPath)!; 156 | } 157 | // Check if a fetch for this doc is already in progress 158 | if (this.pendingDocPromises.has(normalizedPath)) { 159 | logger.debug(`Cache miss, but fetch in progress for: ${normalizedPath}`); 160 | return this.pendingDocPromises.get(normalizedPath)!; 161 | } 162 | 163 | logger.debug(`Cache miss. Reading file: ${normalizedPath}`); 164 | 165 | // Start the fetch and store the promise 166 | const fetchPromise = (async (): Promise => { 167 | try { 168 | const fileContent = await this.fileSystem.readFile(normalizedPath); 169 | const isMarkdown = this.docParser.isMarkdown(normalizedPath); 170 | let doc: Doc; 171 | if (isMarkdown) { 172 | doc = this.docParser.parse(normalizedPath, fileContent); 173 | // Ensure links are extracted when the doc is first parsed 174 | if (!doc.isError) { 175 | // Only extract links if parsing didn't fail 176 | doc.linksTo = this.linkExtractor.extractLinks(normalizedPath, fileContent); 177 | } 178 | } else { 179 | // For non-markdown, create a basic doc entry without parsing frontmatter 180 | doc = this.docParser.getBlankDoc(normalizedPath, { 181 | content: fileContent, 182 | isMarkdown: false, 183 | }); 184 | } 185 | 186 | this.docMap.set(normalizedPath, doc); 187 | return doc; 188 | } catch (error) { 189 | // Log specific error type if available (e.g., ENOENT) 190 | const errorMessage = getErrorMsg(error); 191 | logger.error( 192 | `Error reading or parsing file for graph: ${normalizedPath}. Error: ${errorMessage}` 193 | ); 194 | // Create a minimal placeholder Doc 195 | const errorDoc = this.docParser.getBlankDoc(normalizedPath, { 196 | isError: true, 197 | errorReason: `Error loading content: ${errorMessage}`, 198 | isMarkdown: this.docParser.isMarkdown(normalizedPath), // Try to determine type even on error 199 | }); 200 | this.docMap.set(normalizedPath, errorDoc); // Still add placeholder to map 201 | return errorDoc; 202 | } finally { 203 | // Once fetch is complete (success or error), remove the pending promise 204 | this.pendingDocPromises.delete(normalizedPath); 205 | } 206 | })(); 207 | 208 | this.pendingDocPromises.set(normalizedPath, fetchPromise); 209 | return fetchPromise; 210 | } 211 | 212 | /** 213 | * Gets multiple Docs in parallel using getDoc (which utilizes the cache). 214 | * Filters duplicates from the input list. 215 | */ 216 | async getDocs(absoluteFilePaths: string[]): Promise { 217 | const uniquePaths = Array.from(new Set(absoluteFilePaths)); 218 | return await Promise.all(uniquePaths.map((absoluteFilePath) => this.getDoc(absoluteFilePath))); 219 | } 220 | 221 | /** 222 | * Adds or updates multiple documents in the graph map. 223 | */ 224 | setDocs(docs: Doc[]) { 225 | docs.forEach((doc) => { 226 | this.docMap.set(doc.filePath, doc); 227 | }); 228 | } 229 | 230 | /** 231 | * Returns all markdown docs that are not global and have no auto-attachment globs. 232 | * These are the docs that can be attached automatically by the agent based on the 233 | * description. 234 | */ 235 | getAgentAttachableDocs(): Doc[] { 236 | return this.docs 237 | .filter( 238 | (doc) => 239 | doc.isMarkdown && 240 | doc.meta.description && // Must have a description 241 | !doc.meta.alwaysApply && // Not global 242 | (!doc.meta.globs || doc.meta.globs.length === 0) // No auto-attachment globs 243 | ) 244 | .map((doc) => doc); 245 | } 246 | 247 | /** 248 | * Returns all markdown docs that match the given type. 249 | * @param type - The type of docs to return. 250 | * @returns The docs that match the given type. 251 | */ 252 | getDocsByType(type: DocIndexType): Doc[] { 253 | switch (type) { 254 | case "auto": 255 | return this.docs.filter((doc) => doc.meta.globs && doc.meta.globs.length > 0); 256 | case "agent": 257 | return this.getAgentAttachableDocs(); 258 | case "always": 259 | return this.docs.filter((doc) => doc.meta.alwaysApply); 260 | case "manual": 261 | return this.docs.filter( 262 | (doc) => (!doc.meta.globs || doc.meta.globs.length === 0) && !doc.meta.alwaysApply 263 | ); 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/doc-server.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3 | import { logger } from "./logger.js"; 4 | import { Doc, IDocContextService, IDocIndexService, IFileSystemService } from "./types.js"; 5 | import { z } from "zod"; 6 | import { config } from "./config.js"; 7 | import { createRequire } from "module"; 8 | const require = createRequire(import.meta.url); 9 | const packageJson = require("../package.json"); 10 | 11 | /** 12 | * Markdown Rules MCP Server 13 | * 14 | * @remarks 15 | * This class is responsible for starting the MCP server and handling incoming requests. 16 | * It also initializes the services needed to build the index of all documents in the project. 17 | * 18 | * @example 19 | * ```typescript 20 | * const server = new MarkdownRulesServer(config); 21 | * await server.run(); 22 | * ``` 23 | */ 24 | export class MarkdownRulesServer { 25 | private server: McpServer; 26 | 27 | constructor( 28 | private fileSystem: IFileSystemService, 29 | private docIndex: IDocIndexService, 30 | private docsContextService: IDocContextService 31 | ) { 32 | this.server = new McpServer({ 33 | name: "markdown-rules", 34 | version: packageJson.version || "0.1.0", 35 | }); 36 | 37 | logger.info("Server initialized"); 38 | } 39 | 40 | async setupTools(): Promise { 41 | const configUsageInstructions = await this.getUsageInstructions(); 42 | const agentAttachableDocs = this.docIndex.getDocsByType("agent"); 43 | const descriptions = agentAttachableDocs 44 | .map((doc) => doc.meta.description) 45 | .filter((desc): desc is string => typeof desc === "string"); 46 | 47 | const projectDocsEnum = z.enum(descriptions as [string, ...string[]]); 48 | 49 | this.server.tool( 50 | "get_relevant_docs", 51 | `Get relevant markdown docs inside this project before answering the user's query to help you reply based on more context. 52 | 53 | ${configUsageInstructions}`, 54 | { 55 | attachedFiles: z 56 | .array(z.string().describe("The file path to attach")) 57 | .describe("A list of file paths included in the user's query.") 58 | .optional(), 59 | projectDocs: z 60 | .array(projectDocsEnum) 61 | .describe("A list of docs by their description in the project.") 62 | .optional(), 63 | }, 64 | async ({ attachedFiles = [], projectDocs = [] }) => { 65 | const text = await this.docsContextService.buildContextOutput(attachedFiles, projectDocs); 66 | 67 | const content: { type: "text"; text: string }[] = []; 68 | 69 | content.push({ 70 | type: "text", 71 | text, 72 | }); 73 | 74 | return { 75 | content, 76 | }; 77 | } 78 | ); 79 | 80 | this.server.tool( 81 | "reindex_docs", 82 | "Reindex the docs. Useful for when you want to force a re-index of the docs because there were changes to the docs or the index", 83 | {}, 84 | async () => { 85 | await this.docIndex.buildIndex(); 86 | const totalDocsCount = this.docIndex.docs.length; 87 | await this.setupTools(); // re-register tools 88 | 89 | return { 90 | content: [ 91 | { 92 | type: "text", 93 | text: `Reindexed docs. Found ${totalDocsCount} total docs in the index. To see a summary of the docs in the index, say "List indexed docs".`, 94 | }, 95 | ], 96 | }; 97 | } 98 | ); 99 | 100 | this.server.tool( 101 | "list_indexed_docs", 102 | "Print a full count & summary of the docs in the index. Also shows the usage instructions for the `get_relevant_docs` tool. Useful for debugging. Will only show the first 20 docs in each category & a small preview of the content.", 103 | {}, 104 | async () => { 105 | const createDocSummary = (doc: Doc) => { 106 | return `- ${this.fileSystem.getRelativePath(doc.filePath)}: ${doc.meta.description || doc.content.replace(/\n/g, " ").slice(0, 50).trim()}...`; 107 | }; 108 | 109 | const createDocsPreview = (docs: Doc[], previewLength: number = 20) => { 110 | return ( 111 | docs 112 | .slice(0, previewLength) 113 | .map((doc) => createDocSummary(doc)) 114 | .join("\n") + 115 | (docs.length > previewLength ? `\n...and ${docs.length - previewLength} more...` : "") 116 | ); 117 | }; 118 | 119 | const totalDocsCount = this.docIndex.docs.length; 120 | const agentDocs = this.docIndex.getDocsByType("agent"); 121 | const autoDocs = this.docIndex.getDocsByType("auto"); 122 | const alwaysDocs = this.docIndex.getDocsByType("always"); 123 | const manualDocs = this.docIndex.getDocsByType("manual"); 124 | const extraMessages: { type: "text"; text: string }[] = []; 125 | 126 | if (agentDocs.length > 0) { 127 | extraMessages.push({ 128 | type: "text", 129 | text: `Agent docs preview:\n${createDocsPreview(agentDocs)}`, 130 | }); 131 | } 132 | 133 | if (autoDocs.length > 0) { 134 | extraMessages.push({ 135 | type: "text", 136 | text: `Auto docs preview:\n${createDocsPreview(autoDocs)}`, 137 | }); 138 | } 139 | 140 | if (alwaysDocs.length > 0) { 141 | extraMessages.push({ 142 | type: "text", 143 | text: `Always docs preview:\n${createDocsPreview(alwaysDocs)}`, 144 | }); 145 | } 146 | 147 | if (manualDocs.length > 0) { 148 | extraMessages.push({ 149 | type: "text", 150 | text: `Manual & linked docs preview:\n${createDocsPreview(manualDocs)}`, 151 | }); 152 | } 153 | 154 | return { 155 | content: [ 156 | { 157 | type: "text", 158 | text: `Server version: ${packageJson.version || "0.1.0"} 159 | MCP Root: ${process.cwd()} 160 | Project root: ${config.PROJECT_ROOT} 161 | Markdown include: ${config.MARKDOWN_INCLUDE} 162 | Markdown exclude: ${config.MARKDOWN_EXCLUDE} 163 | Hoist context: ${config.HOIST_CONTEXT}`, 164 | }, 165 | { 166 | type: "text", 167 | text: `Found ${totalDocsCount} total docs in the index: 168 | Agent docs: ${agentDocs.length} 169 | Auto docs: ${autoDocs.length} 170 | Always docs: ${alwaysDocs.length} 171 | Manual & linked docs: ${manualDocs.length}${configUsageInstructions ? `\n\nWith these usage instructions: ${configUsageInstructions?.replace(/\n/g, " ").slice(0, 50)?.trim()}...` : ""}`, 172 | }, 173 | ...extraMessages, 174 | ], 175 | }; 176 | } 177 | ); 178 | 179 | return ["get_relevant_docs", "list_indexed_docs"]; 180 | } 181 | 182 | async getUsageInstructions(): Promise { 183 | const acceptableFilePaths = [ 184 | ...(config.USAGE_INSTRUCTIONS_PATH ? [config.USAGE_INSTRUCTIONS_PATH] : []), 185 | "markdown-rules.md", 186 | "markdown-rules.txt", 187 | "markdown_rules.md", 188 | "markdown_rules.txt", 189 | "MARKDOWN-RULES.md", 190 | "MARKDOWN_RULES.txt", 191 | "MARKDOWN-RULES.txt", 192 | "MARKDOWN_RULES.txt", 193 | ]; 194 | 195 | let usageInstructionsFilePath = null; 196 | for (const filePath of acceptableFilePaths) { 197 | const doesExist = await this.fileSystem.pathExists(filePath); 198 | if (doesExist) { 199 | usageInstructionsFilePath = filePath; 200 | break; 201 | } 202 | } 203 | 204 | if (usageInstructionsFilePath) { 205 | const usageInstructions = await this.fileSystem.readFile(usageInstructionsFilePath); 206 | logger.info(`Found custom usage instructions from file: ${usageInstructionsFilePath}`); 207 | 208 | return usageInstructions; 209 | } 210 | 211 | return `# Usage Instructions 212 | 213 | ## When to use "get_relevant_docs" tool 214 | 215 | * You **must** call the "get_relevant_docs" MCP tool before providing your first response in any new chat session. 216 | * After the initial call in a chat, you should **only** call "get_relevant_docs" again if one of these specific situations occurs: 217 | * The user explicitly requests it. 218 | * The user attaches new files. 219 | * The user's query introduces a completely new topic unrelated to the previous discussion. 220 | 221 | ## How to use "get_relevant_docs" tool 222 | 223 | * "attachedFiles": ALWAYS include file paths the user has attached in their query. 224 | * "projectDocs" 225 | * ONLY include project docs that are VERY RELEVANT to user's query. 226 | * You must have a high confidence when picking docs that may be relevant. 227 | * If the user's query is a generic question unrelated to this specific project, leave this empty. 228 | * Always heavily bias towards leaving this empty.`; 229 | } 230 | 231 | async run(): Promise { 232 | try { 233 | await this.docIndex.buildIndex(); 234 | const agentAttachableDocs = this.docIndex.getDocsByType("agent"); 235 | const autoAttachableDocs = this.docIndex.getDocsByType("auto"); 236 | const alwaysAttachableDocs = this.docIndex.getDocsByType("always"); 237 | const manualAttachableDocs = this.docIndex.getDocsByType("manual"); 238 | const registeredTools = await this.setupTools(); 239 | 240 | logger.info(`Found ${alwaysAttachableDocs.length} always attached docs`); 241 | if (alwaysAttachableDocs.length > 0) { 242 | logger.debug( 243 | `Always attached docs: ${alwaysAttachableDocs 244 | .map((doc) => this.fileSystem.getRelativePath(doc.filePath)) 245 | .join(", ")}` 246 | ); 247 | } 248 | 249 | logger.info(`Found ${autoAttachableDocs.length} auto attachable docs`); 250 | if (autoAttachableDocs.length > 0) { 251 | logger.debug( 252 | `Auto attached docs: ${autoAttachableDocs 253 | .map((doc) => this.fileSystem.getRelativePath(doc.filePath)) 254 | .join(", ")}` 255 | ); 256 | } 257 | 258 | logger.info(`Found ${agentAttachableDocs.length} agent attachable docs`); 259 | if (agentAttachableDocs.length > 0) { 260 | logger.debug( 261 | `Agent attached docs: ${agentAttachableDocs 262 | .map((doc) => this.fileSystem.getRelativePath(doc.filePath)) 263 | .join(", ")}` 264 | ); 265 | } 266 | 267 | logger.info(`Found ${manualAttachableDocs.length} manual attachable docs`); 268 | if (manualAttachableDocs.length > 0) { 269 | logger.debug( 270 | `Manual attached docs: ${manualAttachableDocs 271 | .map((doc) => this.fileSystem.getRelativePath(doc.filePath)) 272 | .join(", ")}` 273 | ); 274 | } 275 | 276 | logger.info( 277 | `Starting server with ${registeredTools.length} tools: ${registeredTools.join(", ")}` 278 | ); 279 | 280 | const transport = new StdioServerTransport(); 281 | 282 | // Handle connection errors 283 | transport.onerror = (error) => { 284 | logger.error(`Transport error: ${error.message}`); 285 | }; 286 | 287 | await this.server.connect(transport); 288 | logger.info("Server running on stdio"); 289 | } catch (error) { 290 | logger.error( 291 | `Server initialization error: ${error instanceof Error ? error.message : String(error)}` 292 | ); 293 | throw error; 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm downloads](https://img.shields.io/npm/dm/@valstro/markdown-rules-mcp)](https://www.npmjs.com/package/@valstro/markdown-rules-mcp) [![smithery badge](https://smithery.ai/badge/@valstro/markdown-rules-mcp)](https://smithery.ai/server/@valstro/markdown-rules-mcp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 2 | 3 | # Markdown Rules MCP Server 4 | 5 | **The portable alternative to Cursor Rules and IDE-specific rules.** 6 | 7 | Transform your project documentation into intelligent AI context using standard Markdown files that work across any MCP-compatible AI tool. Escape vendor lock-in and scattered documentation forever. 8 | 9 | Competing Standards 10 | 11 | ## Why Choose Markdown Rules? 12 | 13 | 🚀 **Universal Compatibility** — Write once, use everywhere. Your documentation works with Cursor, Claude Desktop, and any future MCP-enabled AI tool. No vendor lock-in. 14 | 15 | 🔗 **Smart Dependency Resolution** — Automatically traverse and include linked files & docs, ensuring AI agents receive complete context for complex projects without manual file hunting or relying on the AI agent to follow links. 16 | 17 | 🎯 **Precision Context Control** — Inject exact inline code snippets with line-range embeds (`?md-embed=50-100`) instead of dumping entire files. Get relevant context, not noise. 18 | 19 | 🏗️ **Perfect for Complex Codebases** — Ideal for large projects with custom tooling, internal libraries, or proprietary frameworks that AI models have limited training data for. Provide the context they need to understand your unique architecture. 20 | 21 | ## Prerequisites 📋 22 | 23 | - [Node.js](https://nodejs.org/) (v18 or higher) 24 | - [Cursor](https://www.cursor.com/) or other MCP supported AI coding tools 25 | 26 | ## Installation 🛠️ 27 | 28 | ### Installing via Smithery 29 | 30 | To install the Markdown Rules MCP server for your IDE automatically via [Smithery](https://smithery.ai/server/@valstro/markdown-rules-mcp): 31 | 32 | ```bash 33 | # Cursor 34 | npx -y @smithery/cli install markdown-rules-mcp --client cursor 35 | ``` 36 | 37 | ```bash 38 | # Windsurf 39 | npx -y @smithery/cli install markdown-rules-mcp --client windsurf 40 | ``` 41 | 42 | See [Smithery](https://smithery.ai/server/@valstro/markdown-rules-mcp) for installation options for other IDEs. 43 | 44 | ### Manual Installation 45 | 46 | #### MacOS / Linux 47 | 48 | ```json 49 | { 50 | "mcpServers": { 51 | "markdown-rules-mcp": { 52 | "command": "npx", 53 | "args": ["-y", "@valstro/markdown-rules-mcp@latest"], 54 | "env": { 55 | "PROJECT_ROOT": "/absolute/path/to/project/root", 56 | "MARKDOWN_INCLUDE": "./docs/**/*.md", 57 | "HOIST_CONTEXT": true 58 | } 59 | } 60 | } 61 | } 62 | ``` 63 | 64 | #### Windows 65 | 66 | ```json 67 | { 68 | "mcpServers": { 69 | "markdown-rules-mcp": { 70 | "command": "cmd.exe", 71 | "args": [ 72 | "/c", 73 | "npx", 74 | "-y", 75 | "@valstro/markdown-rules-mcp@latest" 76 | ], 77 | "env": { 78 | "PROJECT_ROOT": "/absolute/path/to/project/root", 79 | "MARKDOWN_INCLUDE": "./docs/**/*.md", 80 | "HOIST_CONTEXT": true 81 | } 82 | } 83 | } 84 | } 85 | ``` 86 | 87 | ## Configuring Usage Instructions (Optional) 88 | 89 | To change the default usage instructions, create a `markdown-rules.md` file in your project root. The file should contain the usage instructions for the `get_relevant_docs` tool. 90 | 91 | The default usage instructions are: 92 | 93 | ```markdown 94 | # Usage Instructions 95 | 96 | * You **must** call the `get_relevant_docs` MCP tool before providing your first response in any new chat session. 97 | * After the initial call in a chat, you should **only** call `get_relevant_docs` again if one of these specific situations occurs: 98 | * The user explicitly requests it. 99 | * The user attaches new files. 100 | * The user's query introduces a completely new topic unrelated to the previous discussion. 101 | ``` 102 | 103 | Note: You can change the default usage instructions file path by adding the `USAGE_INSTRUCTIONS_PATH` environment variable to the MCP server configuration. 104 | 105 | ## Tools 106 | 107 | - `get_relevant_docs` - Get relevant docs based on the user's query. Is called based on the [usage instructions](#configuring-usage-instructions-optional). 108 | - `list_indexed_docs` - Count and preview indexed docs & usage instructions. Useful for debugging. 109 | - `reindex_docs` - Reindex the docs. Useful if docs in the index have changed or new docs have been added. 110 | 111 | ## How To Use 📝 112 | 113 | Create `.md` files in your project with YAML frontmatter to define how they should be included in AI context. 114 | 115 | ### Document Types 116 | 117 | | Type | Frontmatter | Description | When Included | 118 | |------|-------------|-------------|---------------| 119 | | **Global** | `alwaysApply: true` | Always included in every AI conversation | Automatically, every time | 120 | | **Auto-Attached** | `globs: ["**/*.ts", "src/**"]` | Included when attached files match the glob patterns | When you attach matching files | 121 | | **Agent-Requested** | `description: "Brief summary"` | Available for AI to select based on relevance | When AI determines it's relevant to your query | 122 | | **No Frontmatter** | None | Must be included in the prompt manually with @ symbol | When AI determines it's relevant to your query | 123 | 124 | ### Frontmatter Examples 125 | 126 | **Global (always included):** 127 | 128 | ```markdown 129 | --- 130 | description: Project Guidelines 131 | alwaysApply: true 132 | --- 133 | # Project Guidelines 134 | 135 | This doc will always be included. 136 | ``` 137 | 138 | **Auto-attached (included when TypeScript files are attached):** 139 | 140 | ```markdown 141 | --- 142 | description: TypeScript Coding Standards 143 | globs: ["**/*.ts", "**/*.tsx"] 144 | --- 145 | # TypeScript Coding Standards 146 | 147 | This doc will be included when TypeScript files are attached. 148 | ``` 149 | 150 | **Agent-requested (available for AI to select based on relevance):** 151 | ```markdown 152 | --- 153 | description: Database Schema and Migration Guide 154 | --- 155 | # Database Schema and Migration Guide 156 | 157 | This doc will be included when AI selects it based on relevance. 158 | ``` 159 | 160 | **No frontmatter (must be included in the prompt manually with @ symbol):** 161 | 162 | ```markdown 163 | # Testing Guidelines 164 | 165 | This doc needs manual inclusion with @ symbol 166 | ``` 167 | 168 | ### Linking Files 169 | 170 | **Link other files:** Add `?md-link=true` to include linked files in context 171 | ```markdown 172 | See [utilities](./src/utils.ts?md-link=true) for helper functions. 173 | ``` 174 | 175 | **Embed specific lines:** Add `?md-embed=START-END` to include only specific lines inline 176 | ```markdown 177 | Configuration: [API Settings](./config.json?md-embed=1-10) 178 | ``` 179 | 180 | ### Configuration 181 | 182 | - `PROJECT_ROOT` - Default: `process.cwd()` - The absolute path to the project root. 183 | - `MARKDOWN_INCLUDE` - Default: `**/*.md` - Pattern to find markdown doc files 184 | - `HOIST_CONTEXT` - Default: `true` - Whether to show linked files before the docs that reference them 185 | - `MARKDOWN_EXCLUDE` - Default: `**/node_modules/**,**/build/**,**/dist/**,**/.git/**,**/coverage/**,**/.next/**,**/.nuxt/**,**/out/**,**/.cache/**,**/tmp/**,**/temp/**` - Patterns to ignore when finding markdown files 186 | 187 | ## Example 📝 188 | 189 | Imagine you have the following files in your project: 190 | 191 | **`project-overview.md`:** 192 | 193 | ```markdown 194 | --- 195 | description: Project Overview and Setup 196 | alwaysApply: true 197 | --- 198 | # Project Overview 199 | 200 | This document covers the main goals and setup instructions. 201 | 202 | See the [Core Utilities](./src/utils.ts?md-link=true) for essential functions. 203 | 204 | For configuration details, refer to this section: [Config Example](./config.json?md-embed=1-3) 205 | ``` 206 | 207 | **`src/utils.ts`:** 208 | 209 | ```typescript 210 | // src/utils.ts 211 | export function helperA() { 212 | console.log("Helper A"); 213 | } 214 | 215 | export function helperB() { 216 | console.log("Helper B"); 217 | } 218 | ``` 219 | 220 | **`config.json`:** 221 | 222 | ```json 223 | { 224 | "timeout": 5000, 225 | "repeats": 3, 226 | "retries": 3, 227 | "featureFlags": { 228 | "newUI": true 229 | } 230 | } 231 | ``` 232 | 233 | ### Generated Context Output (if `HOIST_CONTEXT` is `true`): 234 | 235 | When the `get_relevant_docs` tool runs, because `project-overview.md` has `alwaysApply: true`, the server would generate context like this: 236 | 237 | ```xml 238 | 239 | // src/utils.ts 240 | export function helperA() { 241 | console.log("Helper A"); 242 | } 243 | 244 | export function helperB() { 245 | console.log("Helper B"); 246 | } 247 | 248 | 249 | 250 | # Project Overview 251 | 252 | This document covers the main goals and setup instructions. 253 | 254 | See the [Core Utilities](./src/utils.ts?md-link=true) for essential functions. 255 | 256 | For configuration details, refer to this section: [Config Example](./config.json?md-embed=1-3) 257 | 258 | "timeout": 5000, 259 | "repeats": "YOUR_API_KEY", 260 | "retries": 3, 261 | 262 | 263 | ``` 264 | 265 | ### Generated Context Output (if `HOIST_CONTEXT` is `false`): 266 | 267 | ```xml 268 | 269 | # Project Overview 270 | 271 | This document covers the main goals and setup instructions. 272 | 273 | See the [Core Utilities](./src/utils.ts?md-link=true) for essential functions. 274 | 275 | For configuration details, refer to this section: [Config Example](./config.json?md-embed=1-3) 276 | 277 | "timeout": 5000, 278 | "repeats": "YOUR_API_KEY", 279 | "retries": 3, 280 | 281 | 282 | 283 | 284 | // src/utils.ts 285 | export function helperA() { 286 | console.log("Helper A"); 287 | } 288 | 289 | export function helperB() { 290 | console.log("Helper B"); 291 | } 292 | 293 | ``` 294 | 295 | ## Caveats & Potential Downsides 296 | 297 | ### Potentially Large Context 298 | 299 | Markdown Rules will diligently parse through all markdown links (?md-link=true) and embeds (e.g., ?md-embed=1-10) to include referenced content. This comprehensiveness can lead to using a significant portion of the AI's context window, especially with deeply linked documentation. 300 | 301 | However, I find this to be a necessary trade-off for providing complete context in the large, bespoke codebases this tool is designed for. 302 | 303 | ### MCP Tool Invocation Variance 304 | 305 | Occasionally, depending on the specific LLM you're using, the model might not call the tool to fetch relevant docs as consistently as one might hope without explicit prompting. This behavior can often be improved by tweaking the usage instructions in your `markdown-rules.md` file or by directly asking the AI to consult the docs. 306 | 307 | I've personally found Anthropic models tend to call the tool very consistently without needing explicit prompts. 308 | 309 | ## Troubleshooting 🔧 310 | 311 | ### Common Issues 312 | 313 | 1. **Tool / Docs Not Being Used** 314 | * Ensure the tool is enabled in the MCP server configuration 315 | * Make sure you're providing an absolute path to the PROJECT_ROOT in the MCP server configuration 316 | * Make sure your `MARKDOWN_INCLUDE` is correct & points to markdown files 317 | * Setup `markdown-rules.md` file in your project root with usage instructions for your needs 318 | * Make sure to wrap your description field in YAML frontmatter in quotes (e.g. `description: "Project Overview"`) 319 | * Make sure to use proper array syntax in the globs field (e.g. `globs: ["**/*.ts", "src/**"]`) 320 | * To debug why your doc isn't being used, you can use the `list_indexed_docs` tool to see what docs are available and what's in the index. Just ask "what docs are available in the index?" 321 | 322 | 2. **New/Updated Docs Not Being Reflected** 323 | * Make sure to restart the server after making changes to docs or the `markdown-rules.md` file (there's no watch mode yet) 324 | 325 | 3. **Server Not Found** 326 | * Verify the npm link is correctly set up 327 | * Check Cursor configuration syntax 328 | * Ensure Node.js is properly installed (v18 or higher) 329 | 330 | 3. **Configuration Issues** 331 | * Make sure your MARKDOWN_INCLUDE is correct 332 | 333 | 4. **Connection Issues** 334 | * Restart Cursor completely 335 | * Check Cursor logs: 336 | 337 | ```bash 338 | # macOS 339 | tail -n 20 -f ~/Library/Logs/Claude/mcp*.log 340 | 341 | # Windows 342 | type "%APPDATA%\Claude\logs\mcp*.log" 343 | ``` 344 | 345 |
346 | 347 | --- 348 | 349 | Built with ❤️ by Valstro 350 | 351 | ## Future Improvements 352 | 353 | - [ ] Support Cursor Rules YAML frontmatter format 354 | - [ ] Add watch mode to re-index docs when markdown files matching the MARKDOWN_INCLUDE have changed 355 | - [ ] Config to limit the number of docs & context that can be attached including a max depth. 356 | - [ ] Config to restrict certain file types from being attached. -------------------------------------------------------------------------------- /src/doc-context.service.ts: -------------------------------------------------------------------------------- 1 | import micromatch from "micromatch"; 2 | import { 3 | ContextItem, 4 | IDocContextService, 5 | IDocFormatterService, 6 | IDocIndexService, 7 | AttachedItemType, 8 | } from "./types.js"; 9 | import { Config } from "./config.js"; 10 | import { logger } from "./logger.js"; 11 | import path from "path"; 12 | 13 | const typePriority: Record = { 14 | always: 0, 15 | auto: 1, 16 | agent: 2, 17 | manual: 3, 18 | related: 4, 19 | }; 20 | 21 | export class DocContextService implements IDocContextService { 22 | constructor( 23 | private config: Config, 24 | private docIndexService: IDocIndexService, 25 | private docFormatterService: IDocFormatterService 26 | ) {} 27 | 28 | /** 29 | * Builds the context items for the given attached files and relevant docs by description. 30 | * @param attachedFiles - The attached files to include in the context. 31 | * @param relevantDocsByDescription - The relevant doc pathsby description to include in the context. 32 | * @returns The context items. 33 | */ 34 | async buildContextItems( 35 | attachedFiles: string[], 36 | relevantDocsByDescription: string[] 37 | ): Promise { 38 | const allDocsMap = this.docIndexService.getDocMap(); 39 | if (allDocsMap.size === 0) { 40 | logger.warn("Document index is empty. Cannot build context."); 41 | return []; 42 | } 43 | 44 | const contextItemsMap = new Map(); 45 | const initialPaths = new Set(); 46 | 47 | // Pre-compute a set of attached file paths for efficient lookup 48 | const attachedFilesSet = new Set(attachedFiles); 49 | 50 | for (const doc of allDocsMap.values()) { 51 | if (doc.isError) continue; 52 | 53 | let currentType: AttachedItemType | null = null; 54 | let currentPriority = Infinity; // Initialize with a high value, lower is better priority 55 | 56 | // Highest priority: Always apply 57 | if (doc.meta.alwaysApply) { 58 | currentType = "always"; 59 | currentPriority = typePriority.always; 60 | } 61 | 62 | // Next priority: Auto glob match 63 | if (currentPriority > typePriority.auto) { 64 | if (doc.meta.globs && doc.meta.globs.length > 0) { 65 | const isMatch = attachedFiles.some((attachedFile) => { 66 | const relativeAttachedFile = path.relative(this.config.PROJECT_ROOT, attachedFile); 67 | return micromatch.isMatch(relativeAttachedFile, doc.meta.globs!, { 68 | dot: true, 69 | }); 70 | }); 71 | if (isMatch) { 72 | currentType = "auto"; 73 | currentPriority = typePriority.auto; 74 | } 75 | } 76 | } 77 | 78 | // Next priority: Agent description match 79 | if (currentPriority > typePriority.agent) { 80 | if (doc.meta.description && relevantDocsByDescription.includes(doc.meta.description)) { 81 | currentType = "agent"; 82 | currentPriority = typePriority.agent; 83 | } 84 | } 85 | 86 | // Lowest explicit priority: Manual attachment 87 | if (currentPriority > typePriority.manual && attachedFilesSet.has(doc.filePath)) { 88 | currentType = "manual"; 89 | } 90 | 91 | // If any type was assigned, add the doc to the context 92 | if (currentType) { 93 | // Check if it already exists and update type only if the new type has strictly *higher* priority (lower number) 94 | const existingItem = contextItemsMap.get(doc.filePath); 95 | if (!existingItem || typePriority[currentType] < typePriority[existingItem.type]) { 96 | contextItemsMap.set(doc.filePath, { doc, type: currentType }); 97 | } 98 | initialPaths.add(doc.filePath); 99 | } 100 | } 101 | 102 | logger.debug( 103 | `Initial context paths (${initialPaths.size}): ${Array.from(initialPaths).join(", ")}` 104 | ); 105 | 106 | const queue = Array.from(initialPaths); 107 | const visited = new Set(initialPaths); // Keep track of visited to avoid cycles and redundant work 108 | 109 | while (queue.length > 0) { 110 | const currentPath = queue.shift()!; 111 | const currentItem = contextItemsMap.get(currentPath); // Should always exist here 112 | 113 | if (!currentItem || !currentItem.doc || currentItem.doc.isError) continue; 114 | 115 | for (const link of currentItem.doc.linksTo) { 116 | if (link.isInline) { 117 | logger.debug(`Skipping traversal for inline link: ${link.filePath} from ${currentPath}`); 118 | continue; 119 | } 120 | 121 | const linkedDoc = allDocsMap.get(link.filePath); 122 | if (!linkedDoc || linkedDoc.isError) { 123 | logger.warn( 124 | `Skipping link to non-existent or error doc: ${link.filePath} from ${currentPath}` 125 | ); 126 | continue; 127 | } 128 | 129 | // Only add related docs if they haven't been added through any other mechanism yet. 130 | // Store the path of the item that introduced this related link. 131 | if (!contextItemsMap.has(link.filePath)) { 132 | contextItemsMap.set(link.filePath, { 133 | doc: linkedDoc, 134 | type: "related", 135 | linkedViaAnchor: link.anchorText, 136 | linkedFromPath: currentPath, // Store the path of the item linking to it 137 | }); 138 | // Add to visited *and* queue only if it wasn't visited before adding it now. 139 | if (!visited.has(link.filePath)) { 140 | visited.add(link.filePath); 141 | queue.push(link.filePath); 142 | logger.debug( 143 | `Added related doc: ${link.filePath} (linked from ${currentPath} via "${link.anchorText}")` 144 | ); 145 | } 146 | } else { 147 | // Log if a doc was already included via a different mechanism 148 | logger.debug( 149 | `Skipping adding ${link.filePath} as related from ${currentPath} as it's already included with type ${contextItemsMap.get(link.filePath)?.type}` 150 | ); 151 | } 152 | } 153 | } 154 | 155 | logger.info(`Total context items before sorting: ${contextItemsMap.size}`); 156 | 157 | let finalItems = Array.from(contextItemsMap.values()); 158 | 159 | finalItems = this.sortItems(finalItems); 160 | 161 | return finalItems; 162 | } 163 | 164 | /** 165 | * Builds the context output for the given attached files and relevant docs by description. 166 | * @param attachedFiles - The attached files to include in the context. 167 | * @param relevantDocsByDescription - The relevant doc pathsby description to include in the context. 168 | * @returns The context output. 169 | */ 170 | async buildContextOutput( 171 | attachedFiles: string[], 172 | relevantDocsByDescription: string[] 173 | ): Promise { 174 | const contextItems = await this.buildContextItems(attachedFiles, relevantDocsByDescription); 175 | return this.docFormatterService.formatContextOutput(contextItems); 176 | } 177 | 178 | /** 179 | * Sorts context items based on type, path, and hoisting rules. 180 | * 181 | * Behavior: 182 | * 1. Non-related items are sorted primarily by type priority (always, auto, agent, manual) 183 | * and secondarily by file path alphabetically. 184 | * 2. Related items are handled based on the `HOIST_CONTEXT` configuration: 185 | * - If true (default): A related item is placed immediately *before* the first non-related item 186 | * (in the sorted order) that links to it. If an item is linked by multiple non-related items, 187 | * it's placed before the one that appears earliest in the sorted non-related list. 188 | * - If false: A related item is placed immediately *after* the first non-related item 189 | * (in the sorted order) that links to it. 190 | * 3. Related items linked only by other related items, or whose linkers are not included 191 | * in the final list (orphans), are appended to the very end, sorted alphabetically by path. 192 | * 4. Multiple related items linked by the same non-related item are sorted alphabetically by path 193 | * relative to each other. 194 | * 195 | * @param items The list of ContextItem objects to sort. 196 | * @returns A new array containing the sorted ContextItem objects. 197 | */ 198 | private sortItems(items: ContextItem[]): ContextItem[] { 199 | const hoistContext = this.config.HOIST_CONTEXT ?? true; // Default to true 200 | 201 | const relatedItems = items.filter((item) => item.type === "related"); 202 | const nonRelatedItems = items.filter((item) => item.type !== "related"); 203 | 204 | // 1. Sort non-related items by type priority then path 205 | nonRelatedItems.sort((a, b) => { 206 | const priorityDiff = typePriority[a.type] - typePriority[b.type]; 207 | if (priorityDiff !== 0) { 208 | return priorityDiff; 209 | } 210 | return a.doc.filePath.localeCompare(b.doc.filePath); 211 | }); 212 | 213 | // 2. Group related items by *all* their non-related linkers and identify orphans 214 | const relatedLinkers = new Map(); // Map: relatedPath -> [linkerPath1, linkerPath2] 215 | const relatedItemsMap = new Map(); // Map: relatedPath -> ContextItem 216 | relatedItems.forEach((item) => relatedItemsMap.set(item.doc.filePath, item)); 217 | 218 | const orphanRelatedItems: ContextItem[] = []; 219 | 220 | for (const relatedItem of relatedItems) { 221 | let hasNonRelatedLinker = false; 222 | for (const potentialLinker of items) { 223 | // Check if potentialLinker is non-related and links to relatedItem 224 | if ( 225 | potentialLinker.type !== "related" && 226 | potentialLinker.doc.linksTo.some((link) => link.filePath === relatedItem.doc.filePath) 227 | ) { 228 | const relatedPath = relatedItem.doc.filePath; 229 | if (!relatedLinkers.has(relatedPath)) { 230 | relatedLinkers.set(relatedPath, []); 231 | } 232 | relatedLinkers.get(relatedPath)!.push(potentialLinker.doc.filePath); 233 | hasNonRelatedLinker = true; 234 | } 235 | } 236 | if (!hasNonRelatedLinker) { 237 | logger.warn( 238 | `Related item ${relatedItem.doc.filePath} has no non-related linkers in the current context. Treating as orphan.` 239 | ); 240 | orphanRelatedItems.push(relatedItem); 241 | } 242 | } 243 | // Sort orphans alphabetically 244 | orphanRelatedItems.sort((a, b) => a.doc.filePath.localeCompare(b.doc.filePath)); 245 | 246 | // 3. Construct final list based on hoistContext 247 | const finalSortedItems: ContextItem[] = []; 248 | const placedRelatedPaths = new Set(); // Keep track of placed related docs 249 | 250 | // Create a map for quick lookup of related items to insert before/after a non-related item 251 | const itemsToInsertByLinker = new Map(); // Map: linkerPath -> [relatedItem1, relatedItem2] 252 | 253 | for (const [relatedPath, linkerPaths] of relatedLinkers.entries()) { 254 | const relatedItem = relatedItemsMap.get(relatedPath); 255 | if (!relatedItem) continue; // Should not happen 256 | 257 | // Find the first non-related item in the sorted list that links to this related item 258 | let firstLinkerPath: string | undefined = undefined; 259 | for (const nrItem of nonRelatedItems) { 260 | if (linkerPaths.includes(nrItem.doc.filePath)) { 261 | firstLinkerPath = nrItem.doc.filePath; 262 | break; 263 | } 264 | } 265 | 266 | if (firstLinkerPath) { 267 | if (!itemsToInsertByLinker.has(firstLinkerPath)) { 268 | itemsToInsertByLinker.set(firstLinkerPath, []); 269 | } 270 | itemsToInsertByLinker.get(firstLinkerPath)!.push(relatedItem); 271 | } else { 272 | // This case might happen if linkers exist but are not in the final nonRelatedItems list (e.g. filtered out previously) 273 | // Treat as orphan for placement purposes 274 | logger.warn( 275 | `Could not find first linker for related item ${relatedPath} among sorted non-related items. Treating as orphan for placement.` 276 | ); 277 | if (!orphanRelatedItems.some((orphan) => orphan.doc.filePath === relatedPath)) { 278 | orphanRelatedItems.push(relatedItem); 279 | // Re-sort orphans just in case 280 | orphanRelatedItems.sort((a, b) => a.doc.filePath.localeCompare(b.doc.filePath)); 281 | } 282 | } 283 | } 284 | 285 | // Sort related items associated with each linker alphabetically 286 | for (const relatedGroup of itemsToInsertByLinker.values()) { 287 | relatedGroup.sort((a, b) => a.doc.filePath.localeCompare(b.doc.filePath)); 288 | } 289 | 290 | // 4. Assemble the final list 291 | for (const nonRelatedItem of nonRelatedItems) { 292 | const linkerPath = nonRelatedItem.doc.filePath; 293 | const relatedGroupToInsert = itemsToInsertByLinker.get(linkerPath) ?? []; 294 | 295 | // Hoist: Place related items *before* their first non-related linker 296 | if (hoistContext) { 297 | for (const relatedItem of relatedGroupToInsert) { 298 | if (!placedRelatedPaths.has(relatedItem.doc.filePath)) { 299 | logger.debug( 300 | `Hoisting related item ${relatedItem.doc.filePath} before linker ${linkerPath}` 301 | ); 302 | finalSortedItems.push(relatedItem); 303 | placedRelatedPaths.add(relatedItem.doc.filePath); 304 | } 305 | } 306 | } 307 | 308 | // Add the non-related item itself 309 | finalSortedItems.push(nonRelatedItem); 310 | 311 | // No Hoist: Place related items *after* their first non-related linker 312 | if (!hoistContext) { 313 | for (const relatedItem of relatedGroupToInsert) { 314 | if (!placedRelatedPaths.has(relatedItem.doc.filePath)) { 315 | logger.debug( 316 | `Placing related item ${relatedItem.doc.filePath} after linker ${linkerPath}` 317 | ); 318 | finalSortedItems.push(relatedItem); 319 | placedRelatedPaths.add(relatedItem.doc.filePath); 320 | } 321 | } 322 | } 323 | } 324 | 325 | // 5. Append any orphan related items at the end 326 | for (const orphan of orphanRelatedItems) { 327 | if (!placedRelatedPaths.has(orphan.doc.filePath)) { 328 | logger.warn(`Appending orphan related item ${orphan.doc.filePath} to the end.`); 329 | finalSortedItems.push(orphan); 330 | placedRelatedPaths.add(orphan.doc.filePath); 331 | } 332 | } 333 | 334 | // 6. Sanity checks (optional) 335 | if (finalSortedItems.length !== items.length) { 336 | logger.error( 337 | `Sorting resulted in item count mismatch! Original: ${items.length}, Sorted: ${finalSortedItems.length}.` 338 | ); 339 | // Log details about missing/extra items 340 | const originalPaths = new Set(items.map((i) => i.doc.filePath)); 341 | const finalPaths = new Set(finalSortedItems.map((i) => i.doc.filePath)); 342 | items.forEach((item) => { 343 | if (!finalPaths.has(item.doc.filePath)) 344 | logger.error(`Missing item: ${item.doc.filePath} (type: ${item.type})`); 345 | }); 346 | finalSortedItems.forEach((item) => { 347 | if (!originalPaths.has(item.doc.filePath)) 348 | logger.error(`Extra item: ${item.doc.filePath} (type: ${item.type})`); 349 | }); 350 | } 351 | const allFoundRelatedCount = placedRelatedPaths.size; 352 | if (relatedItems.length !== allFoundRelatedCount) { 353 | logger.warn( 354 | `Mismatch in related item count. Expected ${relatedItems.length}, placed ${allFoundRelatedCount}. Some related items might be unlinked, orphaned, or linked incorrectly.` 355 | ); 356 | const missedRelated = relatedItems.filter( 357 | (item) => !placedRelatedPaths.has(item.doc.filePath) 358 | ); 359 | missedRelated.forEach((item) => 360 | logger.warn(`-> Unplaced related item: ${item.doc.filePath}`) 361 | ); 362 | } 363 | 364 | return finalSortedItems; 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /src/tests/doc-formatter.service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mocked } from "vitest"; 2 | import { DocFormatterService } from "../doc-formatter.service.js"; 3 | import { 4 | IDocIndexService, 5 | Doc, 6 | ContextItem, 7 | DocLink, 8 | DocLinkRange, 9 | IFileSystemService, 10 | } from "../types.js"; 11 | import { unwrapMock } from "../../setup.tests.js"; // Assuming you have this helper 12 | import { createMockDoc, createMockDocIndexService } from "./__mocks__/doc-index.service.mock.js"; 13 | import { createMockFileSystemService } from "./__mocks__/file-system.service.mock.js"; 14 | 15 | describe("DocFormatterService", () => { 16 | let mockDocIndexService: Mocked; 17 | let mockFileSystemService: Mocked; 18 | let docFormatterService: DocFormatterService; 19 | 20 | const DOC_A_PATH = "/path/docA.md"; 21 | const DOC_A_DIR = "/path"; 22 | const FILE_B_PATH = "/path/fileB.txt"; 23 | const INLINE_DOC_PATH = "/path/inline.md"; 24 | 25 | const docAContent = "Content for Doc A"; 26 | const fileBContent = "Content for File B"; 27 | const inlineDocContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"; 28 | 29 | const docA = createMockDoc(DOC_A_PATH, { content: docAContent }); 30 | const fileB = createMockDoc(FILE_B_PATH, { content: fileBContent, isMarkdown: false }); 31 | const inlineDoc = createMockDoc(INLINE_DOC_PATH, { content: inlineDocContent }); 32 | 33 | beforeEach(() => { 34 | vi.resetAllMocks(); 35 | mockDocIndexService = createMockDocIndexService(); 36 | mockFileSystemService = createMockFileSystemService(); 37 | 38 | mockFileSystemService.getDirname.mockImplementation((filePath) => { 39 | if (filePath === DOC_A_PATH) return DOC_A_DIR; 40 | return "/mock/dir"; 41 | }); 42 | 43 | mockFileSystemService.resolvePath.mockImplementation((base, rel) => { 44 | if ( 45 | base === DOC_A_DIR && 46 | (rel === "./inline.md" || rel === "./inline1.md" || rel === "./inline2.md") 47 | ) { 48 | if (rel === "./inline.md") return INLINE_DOC_PATH; 49 | if (rel === "./inline1.md") return "/path/inline1.md"; 50 | if (rel === "./inline2.md") return "/path/inline2.md"; 51 | } 52 | return `${base}/${rel.startsWith("/") ? "" : "/"}${rel.replace(/^\.\//, "")}`; 53 | }); 54 | 55 | docFormatterService = new DocFormatterService( 56 | unwrapMock(mockDocIndexService), 57 | unwrapMock(mockFileSystemService) 58 | ); 59 | }); 60 | 61 | describe("formatDoc", () => { 62 | it("should format a basic markdown document", async () => { 63 | const item: ContextItem = { doc: docA, type: "auto" }; 64 | const result = await docFormatterService.formatDoc(item); 65 | expect(result).toBe(`\n${docAContent}\n`); 66 | }); 67 | 68 | it("should format a basic non-markdown file", async () => { 69 | const item: ContextItem = { doc: fileB, type: "agent" }; 70 | const result = await docFormatterService.formatDoc(item); 71 | expect(result).toBe(`\n${fileBContent}\n`); 72 | }); 73 | 74 | it("should format a markdown document with a description", async () => { 75 | const docWithDesc = createMockDoc(DOC_A_PATH, { 76 | content: docAContent, 77 | meta: { description: "Doc A Description", globs: [], alwaysApply: false }, 78 | }); 79 | const item: ContextItem = { doc: docWithDesc, type: "related" }; 80 | const result = await docFormatterService.formatDoc(item); 81 | expect(result).toBe( 82 | `\n${docAContent}\n` 83 | ); 84 | }); 85 | 86 | it("should escape quotes in the description attribute", async () => { 87 | const descWithQuotes = 'Description with "quotes"'; 88 | const docWithDesc = createMockDoc(DOC_A_PATH, { 89 | content: docAContent, 90 | meta: { description: descWithQuotes, globs: [], alwaysApply: false }, 91 | }); 92 | const item: ContextItem = { doc: docWithDesc, type: "always" }; 93 | const result = await docFormatterService.formatDoc(item); 94 | expect(result).toBe( 95 | `\n${docAContent}\n` 96 | ); 97 | }); 98 | 99 | it("should format a doc with an inline link", async () => { 100 | const rawTarget = "./inline.md?md-link=true&md-embed=true"; 101 | const linkToInline: DocLink = { 102 | filePath: INLINE_DOC_PATH, 103 | rawLinkTarget: rawTarget, 104 | isInline: true, 105 | anchorText: "Inline Doc Link", 106 | }; 107 | const docContentWithLink = `${docAContent}\n[Inline Doc Link](${rawTarget})`; 108 | const docWithInlineLink = createMockDoc(DOC_A_PATH, { 109 | content: docContentWithLink, 110 | linksTo: [linkToInline], 111 | }); 112 | const item: ContextItem = { doc: docWithInlineLink, type: "auto" }; 113 | 114 | mockDocIndexService.getDoc.mockResolvedValue(inlineDoc); 115 | 116 | const result = await docFormatterService.formatDoc(item); 117 | 118 | expect(mockDocIndexService.getDoc).toHaveBeenCalledWith(INLINE_DOC_PATH); 119 | expect(mockFileSystemService.getDirname).toHaveBeenCalledWith(DOC_A_PATH); 120 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(DOC_A_DIR, "./inline.md"); 121 | 122 | const expectedInlineTag = `\n${inlineDocContent}\n`; 123 | expect(result).toBe( 124 | `\n${docAContent}\n[Inline Doc Link](${rawTarget})\n${expectedInlineTag}\n` 125 | ); 126 | }); 127 | 128 | it("should format a doc with an inline link with line range", async () => { 129 | const range: DocLinkRange = { from: 1, to: 3 }; 130 | const rawTarget = "./inline.md?md-link=true&md-embed=2-4"; 131 | const linkToInline: DocLink = { 132 | filePath: INLINE_DOC_PATH, 133 | rawLinkTarget: rawTarget, 134 | isInline: true, 135 | inlineLinesRange: range, 136 | anchorText: "Inline Section", 137 | }; 138 | const docContentWithLink = `${docAContent}\n[Inline Section](${rawTarget})`; 139 | const docWithInlineLink = createMockDoc(DOC_A_PATH, { 140 | content: docContentWithLink, 141 | linksTo: [linkToInline], 142 | }); 143 | const item: ContextItem = { doc: docWithInlineLink, type: "auto" }; 144 | 145 | mockDocIndexService.getDoc.mockResolvedValue(inlineDoc); 146 | 147 | const result = await docFormatterService.formatDoc(item); 148 | 149 | expect(mockDocIndexService.getDoc).toHaveBeenCalledWith(INLINE_DOC_PATH); 150 | expect(mockFileSystemService.getDirname).toHaveBeenCalledWith(DOC_A_PATH); 151 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(DOC_A_DIR, "./inline.md"); 152 | 153 | const expectedRangeContent = "Line 2\nLine 3\nLine 4"; 154 | const expectedInlineTag = `\n${expectedRangeContent}\n`; 155 | expect(result).toBe( 156 | `\n${docAContent}\n[Inline Section](${rawTarget})\n${expectedInlineTag}\n` 157 | ); 158 | }); 159 | 160 | it("should format a doc with an inline link with line range to 'end'", async () => { 161 | const range: DocLinkRange = { from: 2, to: "end" }; 162 | const rawTarget = "./inline.md?md-link=true&md-embed=3-end"; 163 | const linkToInline: DocLink = { 164 | filePath: INLINE_DOC_PATH, 165 | rawLinkTarget: rawTarget, 166 | isInline: true, 167 | inlineLinesRange: range, 168 | anchorText: "Inline From Line 3", 169 | }; 170 | const docContentWithLink = `${docAContent}\n[Inline From Line 3](${rawTarget})`; 171 | const docWithInlineLink = createMockDoc(DOC_A_PATH, { 172 | content: docContentWithLink, 173 | linksTo: [linkToInline], 174 | }); 175 | const item: ContextItem = { doc: docWithInlineLink, type: "auto" }; 176 | 177 | mockDocIndexService.getDoc.mockResolvedValue(inlineDoc); 178 | 179 | const result = await docFormatterService.formatDoc(item); 180 | 181 | expect(mockDocIndexService.getDoc).toHaveBeenCalledWith(INLINE_DOC_PATH); 182 | expect(mockFileSystemService.getDirname).toHaveBeenCalledWith(DOC_A_PATH); 183 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(DOC_A_DIR, "./inline.md"); 184 | 185 | const expectedRangeContent = "Line 3\nLine 4\nLine 5"; 186 | const expectedInlineTag = `\n${expectedRangeContent}\n`; 187 | expect(result).toBe( 188 | `\n${docAContent}\n[Inline From Line 3](${rawTarget})\n${expectedInlineTag}\n` 189 | ); 190 | }); 191 | 192 | it("should skip inline expansion if linked doc is an error doc", async () => { 193 | const rawTarget = "./inline.md?md-link=true&md-embed=true"; 194 | const linkToInline: DocLink = { 195 | filePath: INLINE_DOC_PATH, 196 | rawLinkTarget: rawTarget, 197 | isInline: true, 198 | anchorText: "Inline Doc Link", 199 | }; 200 | const docContentWithLink = `${docAContent}\n[Inline Doc Link](${rawTarget})`; 201 | const docWithInlineLink = createMockDoc(DOC_A_PATH, { 202 | content: docContentWithLink, 203 | linksTo: [linkToInline], 204 | }); 205 | const item: ContextItem = { doc: docWithInlineLink, type: "auto" }; 206 | const errorInlineDoc = createMockDoc(INLINE_DOC_PATH, { 207 | isError: true, 208 | errorReason: "Read failed", 209 | }); 210 | 211 | mockDocIndexService.getDoc.mockResolvedValue(errorInlineDoc); 212 | 213 | const result = await docFormatterService.formatDoc(item); 214 | 215 | expect(mockDocIndexService.getDoc).toHaveBeenCalledWith(INLINE_DOC_PATH); 216 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(DOC_A_DIR, "./inline.md"); 217 | expect(result).toBe(`\n${docContentWithLink}\n`); 218 | }); 219 | 220 | it("should handle errors when fetching inline doc", async () => { 221 | const rawTarget = "./inline.md?md-link=true&md-embed=true"; 222 | const linkToInline: DocLink = { 223 | filePath: INLINE_DOC_PATH, 224 | rawLinkTarget: rawTarget, 225 | isInline: true, 226 | anchorText: "Inline Doc Link", 227 | }; 228 | const docContentWithLink = `${docAContent}\n[Inline Doc Link](${rawTarget})`; 229 | const docWithInlineLink = createMockDoc(DOC_A_PATH, { 230 | content: docContentWithLink, 231 | linksTo: [linkToInline], 232 | }); 233 | const item: ContextItem = { doc: docWithInlineLink, type: "auto" }; 234 | const fetchError = new Error("Network Error"); 235 | 236 | mockDocIndexService.getDoc.mockRejectedValue(fetchError); 237 | 238 | const result = await docFormatterService.formatDoc(item); 239 | 240 | expect(mockDocIndexService.getDoc).toHaveBeenCalledWith(INLINE_DOC_PATH); 241 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(DOC_A_DIR, "./inline.md"); 242 | expect(result).toBe(`\n${docContentWithLink}\n`); 243 | }); 244 | 245 | it("should format a doc with multiple inline links inserted correctly", async () => { 246 | const inlineDoc1Path = "/path/inline1.md"; 247 | const inlineDoc2Path = "/path/inline2.md"; 248 | const inlineDoc1Content = "Inline Content 1"; 249 | const inlineDoc2Content = "First line C2\nSecond line C2"; 250 | const inlineDoc1 = createMockDoc(inlineDoc1Path, { content: inlineDoc1Content }); 251 | const inlineDoc2 = createMockDoc(inlineDoc2Path, { content: inlineDoc2Content }); 252 | 253 | const rawTarget1 = "./inline1.md?md-link=true&md-embed=true"; 254 | const rawTarget2 = "./inline2.md?md-link=true&md-embed=1-1"; 255 | 256 | const link1: DocLink = { 257 | filePath: inlineDoc1Path, 258 | rawLinkTarget: rawTarget1, 259 | isInline: true, 260 | anchorText: "Link 1", 261 | }; 262 | const link2: DocLink = { 263 | filePath: inlineDoc2Path, 264 | rawLinkTarget: rawTarget2, 265 | isInline: true, 266 | anchorText: "Link 2", 267 | inlineLinesRange: { from: 0, to: 0 }, 268 | }; 269 | 270 | const docContentWithLinks = `Some text before.\n[Link 1](${rawTarget1})\nSome text between.\n[Link 2](${rawTarget2})\nSome text after.`; 271 | 272 | const docWithLinks = createMockDoc(DOC_A_PATH, { 273 | content: docContentWithLinks, 274 | linksTo: [link1, link2], 275 | }); 276 | const item: ContextItem = { doc: docWithLinks, type: "auto" }; 277 | 278 | mockDocIndexService.getDoc.mockImplementation(async (path) => { 279 | if (path === inlineDoc1Path) return inlineDoc1; 280 | if (path === inlineDoc2Path) return inlineDoc2; 281 | throw new Error("Unexpected path"); 282 | }); 283 | mockFileSystemService.resolvePath.mockImplementation((base, rel) => { 284 | if (rel === "./inline1.md") return inlineDoc1Path; 285 | if (rel === "./inline2.md") return inlineDoc2Path; 286 | return `${base}/${rel}`; 287 | }); 288 | 289 | const result = await docFormatterService.formatDoc(item); 290 | 291 | const expectedInlineTag1 = `\n${inlineDoc1Content}\n`; 292 | const expectedInlineTag2 = `\n${inlineDoc2Content.split("\n")[0]}\n`; 293 | 294 | const expectedFinalContent = `Some text before.\n[Link 1](${rawTarget1})\n${expectedInlineTag1}\nSome text between.\n[Link 2](${rawTarget2})\n${expectedInlineTag2}\nSome text after.`; 295 | 296 | expect(result).toBe( 297 | `\n${expectedFinalContent}\n` 298 | ); 299 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(DOC_A_DIR, "./inline1.md"); 300 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(DOC_A_DIR, "./inline2.md"); 301 | }); 302 | }); 303 | 304 | describe("formatContextOutput", () => { 305 | it("should format multiple context items", async () => { 306 | const itemA: ContextItem = { doc: docA, type: "auto" }; 307 | const itemC: ContextItem = { doc: fileB, type: "agent" }; 308 | const items = [itemA, itemC]; 309 | 310 | // Mock formatDoc calls or rely on tested implementation 311 | // For isolation, mock formatDoc: 312 | const formatDocSpy = vi.spyOn(docFormatterService, "formatDoc"); 313 | formatDocSpy.mockResolvedValueOnce("formatted_doc_A"); 314 | formatDocSpy.mockResolvedValueOnce("formatted_file_C"); 315 | 316 | const result = await docFormatterService.formatContextOutput(items); 317 | 318 | expect(formatDocSpy).toHaveBeenCalledTimes(2); 319 | expect(formatDocSpy).toHaveBeenCalledWith(itemA); 320 | expect(formatDocSpy).toHaveBeenCalledWith(itemC); 321 | expect(result).toBe("formatted_doc_A\n\nformatted_file_C"); 322 | }); 323 | 324 | it("should return an empty string for no items", async () => { 325 | const result = await docFormatterService.formatContextOutput([]); 326 | expect(result).toBe(""); 327 | }); 328 | }); 329 | 330 | describe("extractRangeContent", () => { 331 | const multiLineContent = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"; 332 | // Use internal access for testing private method - adjust if needed 333 | const extractRangeContent = (content: string, range?: DocLinkRange) => { 334 | // @ts-expect-error Accessing private method 335 | return docFormatterService.extractRangeContent(content, range); 336 | }; 337 | 338 | it("should return full content if no range is provided", () => { 339 | expect(extractRangeContent(multiLineContent)).toBe(multiLineContent); 340 | }); 341 | 342 | it("should extract content for a valid from-to range", () => { 343 | // Lines 2, 3, 4 (indices 1, 2, 3) 344 | expect(extractRangeContent(multiLineContent, { from: 1, to: 3 })).toBe( 345 | "Line 2\nLine 3\nLine 4" 346 | ); 347 | }); 348 | 349 | it("should extract content for a range starting from 0", () => { 350 | // Lines 1, 2 (indices 0, 1) 351 | expect(extractRangeContent(multiLineContent, { from: 0, to: 1 })).toBe("Line 1\nLine 2"); 352 | }); 353 | 354 | it("should extract content for a range ending at 'end'", () => { 355 | // Lines 3, 4, 5 (indices 2, 3, 4) 356 | expect(extractRangeContent(multiLineContent, { from: 2, to: "end" })).toBe( 357 | "Line 3\nLine 4\nLine 5" 358 | ); 359 | }); 360 | 361 | it("should handle range end exceeding content length", () => { 362 | // Lines 4, 5 (indices 3, 4) - asking for up to index 10 363 | expect(extractRangeContent(multiLineContent, { from: 3, to: 10 })).toBe("Line 4\nLine 5"); 364 | }); 365 | 366 | it("should handle range start exceeding content length", () => { 367 | expect(extractRangeContent(multiLineContent, { from: 10, to: 15 })).toBe(""); 368 | }); 369 | 370 | it("should handle various range scenarios", () => { 371 | // Invalid range: start > end returns empty string 372 | expect(extractRangeContent(multiLineContent, { from: 3, to: 2 })).toBe(""); 373 | // Valid range: start === end extracts one line (index = start) 374 | expect(extractRangeContent(multiLineContent, { from: 3, to: 3 })).toBe("Line 4"); 375 | }); 376 | }); 377 | 378 | describe("formatRange", () => { 379 | // Use internal access for testing private method 380 | const formatRange = (range: DocLinkRange) => { 381 | // @ts-expect-error Accessing private method 382 | return docFormatterService.formatRange(range); 383 | }; 384 | it("should format from-to range", () => { 385 | expect(formatRange({ from: 9, to: 19 })).toBe("10-20"); 386 | }); 387 | it("should format from-end range", () => { 388 | expect(formatRange({ from: 4, to: "end" })).toBe("5-end"); 389 | }); 390 | }); 391 | }); 392 | -------------------------------------------------------------------------------- /src/tests/doc-context.service.integration.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; 2 | import fs from "fs/promises"; 3 | import path from "path"; 4 | import os from "os"; 5 | import { DocContextService } from "../doc-context.service.js"; 6 | import { DocIndexService } from "../doc-index.service.js"; 7 | import { DocParserService } from "../doc-parser.service.js"; 8 | import { LinkExtractorService } from "../link-extractor.service.js"; 9 | import { FileSystemService } from "../file-system.service.js"; 10 | import { DocFormatterService } from "../doc-formatter.service.js"; 11 | import { Config } from "../config.js"; 12 | import { IDocIndexService } from "../types.js"; // Import necessary types if needed later 13 | import { createConfigMock } from "./__mocks__/config.mock.js"; 14 | 15 | describe("DocContextService Integration Tests", () => { 16 | let tempDir: string; 17 | let mockConfig: Config; 18 | let fileSystemService: FileSystemService; 19 | let docParserService: DocParserService; 20 | let linkExtractorService: LinkExtractorService; 21 | let docIndexService: IDocIndexService; 22 | let docFormatterService: DocFormatterService; 23 | let docContextService: DocContextService; 24 | 25 | // File paths (relative to tempDir) 26 | const alwaysDocPathRel = "always.md"; 27 | const autoTsDocPathRel = "auto-ts.md"; 28 | const agentDocPathRel = "agent-trigger.md"; 29 | const relatedDocPathRel = "related.md"; 30 | const relatedDoc2PathRel = "related2.md"; 31 | const manualDocPathRel = "manual.md"; 32 | const inlineTargetDocPathRel = "inline-target.md"; 33 | const mainTsPathRel = "src/main.ts"; 34 | const unrelatedDocPathRel = "unrelated.md"; 35 | const cycleADocPathRel = "cycle-a.md"; 36 | const cycleBDocPathRel = "cycle-b.md"; 37 | const configFileRel = "config.json"; 38 | 39 | // Absolute paths 40 | let alwaysDocPathAbs: string; 41 | let autoTsDocPathAbs: string; 42 | let agentDocPathAbs: string; 43 | let agentDocDescription: string; 44 | let relatedDocPathAbs: string; 45 | let relatedDoc2PathAbs: string; 46 | let manualDocPathAbs: string; 47 | let inlineTargetDocPathAbs: string; 48 | let mainTsPathAbs: string; 49 | let unrelatedDocPathAbs: string; 50 | let cycleADocPathAbs: string; 51 | let cycleADocDescription: string; 52 | let cycleBDocPathAbs: string; 53 | let cycleBDocDescription: string; 54 | let configFileAbs: string; 55 | 56 | // Setup 57 | let toRelative: (filePath: string) => string; 58 | let setup: (config?: Partial) => Promise; 59 | 60 | beforeAll(async () => { 61 | // Create a unique temporary directory for this test suite 62 | tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-rules-test-")); 63 | 64 | // Define absolute paths 65 | alwaysDocPathAbs = path.join(tempDir, alwaysDocPathRel); 66 | autoTsDocPathAbs = path.join(tempDir, autoTsDocPathRel); 67 | agentDocPathAbs = path.join(tempDir, agentDocPathRel); 68 | agentDocDescription = "Agent Triggered Doc"; 69 | relatedDocPathAbs = path.join(tempDir, relatedDocPathRel); 70 | relatedDoc2PathAbs = path.join(tempDir, relatedDoc2PathRel); 71 | manualDocPathAbs = path.join(tempDir, manualDocPathRel); 72 | inlineTargetDocPathAbs = path.join(tempDir, inlineTargetDocPathRel); 73 | mainTsPathAbs = path.join(tempDir, mainTsPathRel); 74 | unrelatedDocPathAbs = path.join(tempDir, unrelatedDocPathRel); 75 | cycleADocPathAbs = path.join(tempDir, cycleADocPathRel); 76 | cycleADocDescription = "Cycle A"; 77 | cycleBDocPathAbs = path.join(tempDir, cycleBDocPathRel); 78 | cycleBDocDescription = "Cycle B"; 79 | configFileAbs = path.join(tempDir, configFileRel); 80 | 81 | // Create necessary subdirectories 82 | await fs.mkdir(path.join(tempDir, "src"), { recursive: true }); 83 | 84 | // Create test files 85 | await fs.writeFile( 86 | alwaysDocPathAbs, 87 | `--- 88 | description: Always Included 89 | alwaysApply: true 90 | --- 91 | This doc is always present. 92 | It links to [Related Doc](./related.md?md-link=true).` 93 | ); 94 | 95 | await fs.writeFile( 96 | autoTsDocPathAbs, 97 | `--- 98 | description: Auto TS Inclusion 99 | globs: ["**/*.ts"] 100 | --- 101 | This doc applies to TypeScript files. 102 | It has an inline link: [Inline Target Section](./inline-target.md?md-embed=1-2)` 103 | ); 104 | 105 | await fs.writeFile( 106 | agentDocPathAbs, 107 | `--- 108 | description: ${agentDocDescription} 109 | --- 110 | This doc is triggered by the agent description match. 111 | 112 | and is related to [Related Doc 2](./related2.md?md-link=true).` 113 | ); 114 | 115 | await fs.writeFile( 116 | relatedDocPathAbs, 117 | `--- 118 | description: Related Doc 119 | --- 120 | This doc is linked from the 'always' doc.` 121 | ); 122 | 123 | await fs.writeFile( 124 | relatedDoc2PathAbs, 125 | `--- 126 | description: Related Doc 2 127 | --- 128 | This doc is related to various things.` 129 | ); 130 | 131 | await fs.writeFile( 132 | inlineTargetDocPathAbs, 133 | `Line 1 134 | Line 2 135 | Line 3 136 | Line 4 (end)` 137 | ); 138 | 139 | await fs.writeFile(mainTsPathAbs, `console.log("Hello from main.ts");`); 140 | 141 | await fs.writeFile( 142 | unrelatedDocPathAbs, 143 | `--- 144 | description: Unrelated Doc 145 | --- 146 | This doc should not be included unless directly linked or triggered.` 147 | ); 148 | 149 | await fs.writeFile( 150 | cycleADocPathAbs, 151 | `--- 152 | description: ${cycleADocDescription} 153 | --- 154 | Links to [Cycle B](./cycle-b.md?md-link=true)` 155 | ); 156 | 157 | await fs.writeFile( 158 | cycleBDocPathAbs, 159 | `--- 160 | description: ${cycleBDocDescription} 161 | --- 162 | Links back to [Cycle A](./cycle-a.md?md-link=true)` 163 | ); 164 | 165 | await fs.writeFile(configFileAbs, `{ "config": "value" }`); // Non-markdown file 166 | 167 | await fs.writeFile( 168 | manualDocPathAbs, 169 | `--- 170 | description: Manual Doc 171 | --- 172 | This doc is manually included.` 173 | ); 174 | }); 175 | 176 | afterAll(async () => { 177 | // Clean up the temporary directory 178 | if (tempDir) { 179 | await fs.rm(tempDir, { recursive: true, force: true }); 180 | } 181 | }); 182 | 183 | beforeEach(async () => { 184 | mockConfig = createConfigMock({ 185 | PROJECT_ROOT: tempDir, 186 | LOG_LEVEL: "error", // Keep logs quiet during tests unless debugging 187 | }); 188 | fileSystemService = new FileSystemService(mockConfig); 189 | docParserService = new DocParserService(); 190 | linkExtractorService = new LinkExtractorService(fileSystemService); 191 | toRelative = (filePath: string) => { 192 | return path.relative(mockConfig.PROJECT_ROOT, filePath); 193 | }; 194 | 195 | setup = async (config: Partial = {}) => { 196 | mockConfig = { ...mockConfig, ...config }; 197 | const currentFileSystemService = new FileSystemService(mockConfig); 198 | linkExtractorService = new LinkExtractorService(currentFileSystemService); 199 | 200 | docIndexService = new DocIndexService( 201 | mockConfig, 202 | currentFileSystemService, 203 | docParserService, 204 | linkExtractorService 205 | ); 206 | docFormatterService = new DocFormatterService(docIndexService, currentFileSystemService); 207 | docContextService = new DocContextService(mockConfig, docIndexService, docFormatterService); 208 | await docIndexService.buildIndex(); 209 | }; 210 | 211 | await setup(); 212 | }); 213 | 214 | it("should include 'always' and 'related' docs with empty input", async () => { 215 | const output = await docContextService.buildContextOutput([], []); 216 | const expectedOutput = ` 217 | This doc is linked from the 'always' doc. 218 | 219 | 220 | 221 | This doc is always present. 222 | It links to [Related Doc](./related.md?md-link=true). 223 | `; 224 | expect(nl(output)).toBe(nl(expectedOutput)); 225 | }); 226 | 227 | it("should include 'auto' doc when attached file matches glob", async () => { 228 | const output = await docContextService.buildContextOutput([mainTsPathAbs], []); 229 | const expectedInlineContent = "Line 1\nLine 2"; 230 | const expectedOutput = ` 231 | This doc is linked from the 'always' doc. 232 | 233 | 234 | 235 | This doc is always present. 236 | It links to [Related Doc](./related.md?md-link=true). 237 | 238 | 239 | 240 | This doc applies to TypeScript files. 241 | It has an inline link: [Inline Target Section](./inline-target.md?md-embed=1-2) 242 | 243 | ${expectedInlineContent} 244 | 245 | `; 246 | expect(nl(output)).toBe(nl(expectedOutput)); 247 | }); 248 | 249 | it("should include 'agent' doc & its related doc when its path is provided", async () => { 250 | const output = await docContextService.buildContextOutput([], [agentDocDescription]); 251 | const expectedOutput = ` 252 | This doc is linked from the 'always' doc. 253 | 254 | 255 | 256 | This doc is always present. 257 | It links to [Related Doc](./related.md?md-link=true). 258 | 259 | 260 | 261 | This doc is related to various things. 262 | 263 | 264 | 265 | This doc is triggered by the agent description match. 266 | and is related to [Related Doc 2](./related2.md?md-link=true). 267 | `; 268 | expect(nl(output)).toBe(nl(expectedOutput)); 269 | }); 270 | 271 | it("should include 'manual' doc when its path is provided", async () => { 272 | const output = await docContextService.buildContextOutput([manualDocPathAbs], []); 273 | const expectedOutput = ` 274 | This doc is linked from the 'always' doc. 275 | 276 | 277 | 278 | This doc is always present. 279 | It links to [Related Doc](./related.md?md-link=true). 280 | 281 | 282 | 283 | This doc is manually included. 284 | `; 285 | expect(nl(output)).toBe(nl(expectedOutput)); 286 | }); 287 | 288 | it("should handle cycles gracefully", async () => { 289 | const output = await docContextService.buildContextOutput([], [cycleADocDescription]); 290 | const expectedOutput = ` 291 | This doc is linked from the 'always' doc. 292 | 293 | 294 | 295 | This doc is always present. 296 | It links to [Related Doc](./related.md?md-link=true). 297 | 298 | 299 | 300 | Links back to [Cycle A](./cycle-a.md?md-link=true) 301 | 302 | 303 | 304 | Links to [Cycle B](./cycle-b.md?md-link=true) 305 | `; 306 | expect(nl(output)).toBe(nl(expectedOutput)); 307 | }); 308 | 309 | it("should include non-markdown files linked via include=true as ", async () => { 310 | // Modify 'always' doc for this test to link to config.json 311 | const alwaysWithJsonLink = `--- 312 | description: Always Included 313 | alwaysApply: true 314 | --- 315 | This doc is always present. 316 | It links to [Config File](./config.json?md-link=true).`; 317 | await fs.writeFile(alwaysDocPathAbs, alwaysWithJsonLink); 318 | await docIndexService.buildIndex(); // Re-index 319 | 320 | const output = await docContextService.buildContextOutput([], []); 321 | const expectedOutput = ` 322 | { "config": "value" } 323 | 324 | 325 | 326 | This doc is always present. 327 | It links to [Config File](./config.json?md-link=true). 328 | `; 329 | expect(nl(output)).toBe(nl(expectedOutput)); 330 | 331 | // Restore original always doc content for other tests (or use afterEach) 332 | await fs.writeFile( 333 | alwaysDocPathAbs, 334 | `--- 335 | description: Always Included 336 | alwaysApply: true 337 | --- 338 | This doc is always present. 339 | It links to [Related Doc](./related.md?md-link=true).` 340 | ); 341 | }); 342 | 343 | it("should produce empty output when no docs are selected", async () => { 344 | // Create a new index service pointing to an empty temp dir for this test 345 | const emptyTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mcp-empty-test-")); 346 | const emptyConfig = { ...mockConfig, PROJECT_ROOT: emptyTempDir }; 347 | const emptyFs = new FileSystemService(emptyConfig); 348 | const emptyIndex = new DocIndexService( 349 | emptyConfig, 350 | emptyFs, 351 | docParserService, // Can reuse parser/extractor 352 | linkExtractorService 353 | ); 354 | const emptyFormatter = new DocFormatterService(emptyIndex, emptyFs); 355 | const emptyContext = new DocContextService(emptyConfig, emptyIndex, emptyFormatter); 356 | 357 | await emptyIndex.buildIndex(); // Build index on empty dir 358 | const output = await emptyContext.buildContextOutput([], []); 359 | expect(output).toBe(""); 360 | 361 | await fs.rm(emptyTempDir, { recursive: true, force: true }); // Clean up empty dir 362 | }); 363 | 364 | it("should hoist context correctly, with multiple 'always' docs linked to same doc", async () => { 365 | // Create a second 'always' doc that links to the same doc 366 | const alwaysDocPathRel2 = "always-2.md"; 367 | const alwaysDocPathAbs2 = path.join(tempDir, alwaysDocPathRel2); 368 | await fs.writeFile( 369 | alwaysDocPathAbs2, 370 | `--- 371 | alwaysApply: true 372 | --- 373 | This 2nd 'always' doc is always present AND has no description. 374 | It links to [Related Doc](./related.md?md-link=true).` 375 | ); 376 | await docIndexService.buildIndex(); // Re-index 377 | 378 | const output = await docContextService.buildContextOutput([], []); 379 | const expectedOutput = ` 380 | This doc is linked from the 'always' doc. 381 | 382 | 383 | 384 | This 2nd 'always' doc is always present AND has no description. 385 | It links to [Related Doc](./related.md?md-link=true). 386 | 387 | 388 | 389 | This doc is always present. 390 | It links to [Related Doc](./related.md?md-link=true). 391 | `; 392 | expect(nl(output)).toBe(nl(expectedOutput)); 393 | 394 | // Clean up specific files for this test 395 | await fs.unlink(alwaysDocPathAbs2); 396 | }); 397 | 398 | it("should NOT hoist context when configured", async () => { 399 | // Create specific files for this test to ensure clear dependency 400 | const preAPathRel = "pre-a.md"; 401 | const preBPathRel = "pre-b.md"; 402 | const preAPathAbs = path.join(tempDir, preAPathRel); 403 | const preADescription = "Pre A (Agent Trigger)"; 404 | const preBPathAbs = path.join(tempDir, preBPathRel); 405 | const preBDescription = "Pre B (Related)"; 406 | 407 | await fs.writeFile( 408 | preAPathAbs, 409 | `--- 410 | description: ${preADescription} 411 | --- 412 | Links to [Pre B](./pre-b.md?md-link=true)` 413 | ); 414 | await fs.writeFile( 415 | preBPathAbs, 416 | `--- 417 | description: ${preBDescription} 418 | --- 419 | This is related to Pre A.` 420 | ); 421 | 422 | await setup({ HOIST_CONTEXT: false }); 423 | 424 | const output = await docContextService.buildContextOutput([], [preADescription]); 425 | 426 | const expectedOutput = ` 427 | This doc is always present. 428 | It links to [Related Doc](./related.md?md-link=true). 429 | 430 | 431 | 432 | This doc is linked from the 'always' doc. 433 | 434 | 435 | 436 | Links to [Pre B](./pre-b.md?md-link=true) 437 | 438 | 439 | 440 | This is related to Pre A. 441 | `; 442 | 443 | expect(nl(output)).toBe(nl(expectedOutput)); 444 | 445 | // Clean up specific files for this test 446 | await fs.unlink(preAPathAbs); 447 | await fs.unlink(preBPathAbs); 448 | }); 449 | 450 | it("should handle complex inline ranges correctly", async () => { 451 | // Modify auto-ts.md to include more range types 452 | const autoTsExtendedContent = `--- 453 | description: Auto TS Inclusion Extended Ranges 454 | globs: ["**/*.ts"] 455 | --- 456 | This doc applies to TypeScript files. 457 | Range 1-2: [Inline 1-2](./inline-target.md?md-link=true&md-embed=1-2) 458 | Range 0-1: [Inline 0-1](./inline-target.md?md-link=true&md-embed=-1) 459 | Range 2-end: [Inline 2-end](./inline-target.md?md-link=true&md-embed=2-) 460 | Single Line 3: [Inline 3-3](./inline-target.md?md-link=true&md-embed=3-3)`; 461 | await fs.writeFile(autoTsDocPathAbs, autoTsExtendedContent); 462 | await docIndexService.buildIndex(); // Re-index 463 | 464 | const output = await docContextService.buildContextOutput([mainTsPathAbs], []); 465 | 466 | const expectedInline_1_2 = "Line 1\nLine 2"; 467 | const expectedInline_0_1 = "Line 1"; 468 | const expectedInline_2_end = "Line 2\nLine 3\nLine 4 (end)"; 469 | const expectedInline_3_3 = "Line 3"; 470 | const expectedOutput = ` 471 | This doc is linked from the 'always' doc. 472 | 473 | 474 | 475 | This doc is always present. 476 | It links to [Related Doc](./related.md?md-link=true). 477 | 478 | 479 | 480 | This doc applies to TypeScript files. 481 | Range 1-2: [Inline 1-2](./inline-target.md?md-link=true&md-embed=1-2) 482 | 483 | ${expectedInline_1_2} 484 | 485 | Range 0-1: [Inline 0-1](./inline-target.md?md-link=true&md-embed=-1) 486 | 487 | ${expectedInline_0_1} 488 | 489 | Range 2-end: [Inline 2-end](./inline-target.md?md-link=true&md-embed=2-) 490 | 491 | ${expectedInline_2_end} 492 | 493 | Single Line 3: [Inline 3-3](./inline-target.md?md-link=true&md-embed=3-3) 494 | 495 | ${expectedInline_3_3} 496 | 497 | `; 498 | expect(nl(output)).toBe(nl(expectedOutput)); 499 | 500 | // Restore original auto-ts doc 501 | await fs.writeFile( 502 | autoTsDocPathAbs, 503 | `--- 504 | description: Auto TS Inclusion 505 | globs: ["**/*.ts"] 506 | --- 507 | This doc applies to TypeScript files. 508 | It has an inline link: [Inline Target Section](./inline-target.md?md-embed=1-2)` 509 | ); 510 | }); 511 | 512 | it("should include doc once if matched by multiple globs from attached files", async () => { 513 | // Create a doc with multiple globs and a JS file to attach 514 | const multiGlobDocRel = "multi-glob.md"; 515 | const multiGlobDocAbs = path.join(tempDir, multiGlobDocRel); 516 | const utilJsPathRel = "src/util.js"; 517 | const utilJsPathAbs = path.join(tempDir, utilJsPathRel); 518 | 519 | await fs.writeFile( 520 | multiGlobDocAbs, 521 | `--- 522 | description: Multi Glob Test 523 | globs: ["**/*.ts", "**/*.js"] 524 | --- 525 | This should apply to TS and JS files.` 526 | ); 527 | await fs.writeFile(utilJsPathAbs, `// Util JS file`); 528 | await docIndexService.buildIndex(); // Re-index 529 | 530 | // Attach both a .ts and a .js file 531 | const output = await docContextService.buildContextOutput([mainTsPathAbs, utilJsPathAbs], []); 532 | const expectedOutput = ` 533 | This doc is linked from the 'always' doc. 534 | 535 | 536 | 537 | This doc is always present. 538 | It links to [Related Doc](./related.md?md-link=true). 539 | 540 | 541 | 542 | This doc applies to TypeScript files. 543 | It has an inline link: [Inline Target Section](./inline-target.md?md-embed=1-2) 544 | 545 | Line 1 546 | Line 2 547 | 548 | 549 | 550 | 551 | This should apply to TS and JS files. 552 | `; 553 | expect(nl(output)).toBe(nl(expectedOutput)); 554 | // Verify the multi-glob doc appears only once 555 | const multiGlobCount = (output.match(new RegExp(toRelative(multiGlobDocAbs), "g")) || []) 556 | .length; 557 | expect(multiGlobCount).toBe(1); 558 | 559 | // Clean up specific files 560 | await fs.unlink(multiGlobDocAbs); 561 | await fs.unlink(utilJsPathAbs); 562 | }); 563 | 564 | it("should prioritize 'auto' type over 'agent' type if doc matches both", async () => { 565 | // Create a doc that matches a glob and is also provided as agent-relevant 566 | const autoAgentDocRel = "auto-agent.md"; 567 | const autoAgentDocAbs = path.join(tempDir, autoAgentDocRel); 568 | 569 | await fs.writeFile( 570 | autoAgentDocAbs, 571 | `--- 572 | description: Auto Agent Doc 573 | globs: ["*.json"] 574 | --- 575 | This matches JSON glob and could be agent-triggered.` 576 | ); 577 | await docIndexService.buildIndex(); // Re-index 578 | 579 | // Attach config.json (matches glob) AND provide the doc path via agent list 580 | const output = await docContextService.buildContextOutput([configFileAbs], [autoAgentDocAbs]); 581 | const expectedOutput = ` 582 | This doc is linked from the 'always' doc. 583 | 584 | 585 | 586 | This doc is always present. 587 | It links to [Related Doc](./related.md?md-link=true). 588 | 589 | 590 | 591 | This matches JSON glob and could be agent-triggered. 592 | `; 593 | expect(nl(output)).toBe(nl(expectedOutput)); 594 | 595 | // Clean up specific file 596 | await fs.unlink(autoAgentDocAbs); 597 | }); 598 | }); 599 | 600 | /** 601 | * Normalize line endings to Unix style 602 | */ 603 | function nl(str: string) { 604 | return str.replace(/\r\n/g, "\n"); 605 | } 606 | -------------------------------------------------------------------------------- /src/tests/doc-index.service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mocked } from "vitest"; 2 | import { DocIndexService } from "../doc-index.service.js"; 3 | import { 4 | IFileSystemService, 5 | IDocIndexService, 6 | IDocParserService, 7 | ILinkExtractorService, 8 | DocLink, 9 | DocIndex, 10 | } from "../types.js"; 11 | import { unwrapMock } from "../../setup.tests.js"; 12 | import { Config } from "../config.js"; 13 | import { createMockDoc } from "./__mocks__/doc-index.service.mock.js"; 14 | import { createMockFileSystemService } from "./__mocks__/file-system.service.mock.js"; 15 | import { createMockDocParserService } from "./__mocks__/doc-parser.service.mock.js"; 16 | import { createMockLinkExtractorService } from "./__mocks__/link-extractor.service.mock.js"; 17 | import { createConfigMock } from "./__mocks__/config.mock.js"; 18 | 19 | vi.mock("./file-system.service.js"); 20 | vi.mock("./doc-parser.service.js"); 21 | vi.mock("./link-extractor.service.js"); 22 | describe("DocIndexService", () => { 23 | let mockFileSystemService: Mocked; 24 | let mockDocParserService: Mocked; 25 | let mockLinkExtractorService: Mocked; 26 | let mockConfig: Config; 27 | let docIndexService: IDocIndexService; 28 | 29 | const FILE_A = "/project/docA.md"; 30 | const FILE_B = "/project/subdir/docB.md"; // Put B in a subdir 31 | const FILE_C = "/project/subdir/docC.md"; 32 | const FILE_D = "/project/docD.md"; 33 | const FILE_E = "/project/docE.md"; 34 | const FILE_JSON = "/project/config.json"; 35 | 36 | beforeEach(() => { 37 | vi.resetAllMocks(); // Reset mocks between tests 38 | 39 | mockFileSystemService = createMockFileSystemService(); 40 | mockDocParserService = createMockDocParserService(); 41 | mockLinkExtractorService = createMockLinkExtractorService(); 42 | mockConfig = createConfigMock({}); 43 | 44 | // Default implementations that can be overridden in specific tests 45 | mockFileSystemService.resolvePath.mockImplementation((base, relative) => { 46 | // Basic relative path resolution needed for link extraction tests 47 | if (!relative) return base; 48 | if (relative.startsWith("/")) return relative; // Already absolute 49 | const parts = base.split("/"); 50 | parts.pop(); // Remove filename if base is a file path 51 | const relParts = relative.split("/"); 52 | for (const part of relParts) { 53 | if (part === "..") { 54 | parts.pop(); 55 | } else if (part !== ".") { 56 | parts.push(part); 57 | } 58 | } 59 | return parts.join("/"); 60 | }); 61 | 62 | mockDocParserService.getBlankDoc.mockImplementation((filePath, docOverride) => 63 | createMockDoc(filePath, { 64 | content: docOverride?.content ?? "", 65 | linksTo: [], 66 | isMarkdown: mockDocParserService.isMarkdown(filePath), 67 | isError: docOverride?.isError ?? false, 68 | errorReason: docOverride?.errorReason, 69 | }) 70 | ); 71 | 72 | docIndexService = new DocIndexService( 73 | mockConfig, 74 | unwrapMock(mockFileSystemService), 75 | unwrapMock(mockDocParserService), 76 | unwrapMock(mockLinkExtractorService) 77 | ); 78 | }); 79 | 80 | it("should build an index with a single document and no links", async () => { 81 | const docAContent = "# Doc A"; 82 | const docA = createMockDoc(FILE_A, { content: docAContent }); 83 | 84 | mockFileSystemService.findFiles.mockResolvedValue([FILE_A]); 85 | mockFileSystemService.readFile.mockResolvedValue(docAContent); 86 | mockDocParserService.parse.mockReturnValue(docA); 87 | mockLinkExtractorService.extractLinks.mockReturnValue([]); // No links from Doc A 88 | 89 | const index = await docIndexService.buildIndex(); 90 | 91 | expect(mockFileSystemService.findFiles).toHaveBeenCalledTimes(1); 92 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_A); 93 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_A, docAContent); 94 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledWith(FILE_A, docAContent); 95 | expect(index.size).toBe(1); 96 | expect(index.get(FILE_A)).toEqual(docA); // Check the doc content 97 | expect(index.get(FILE_A)?.linksTo).toEqual([]); // Ensure linksTo is empty 98 | }); 99 | 100 | it("should build an index with nested dependencies (A -> B -> C)", async () => { 101 | const docAContent = "# Doc A\n[Link to B](./subdir/docB.md?md-link=true)"; 102 | const docBContent = "# Doc B\n[Link to C](./docC.md?md-link=true)"; 103 | const docCContent = "# Doc C"; 104 | const docA = createMockDoc(FILE_A, { content: docAContent }); 105 | const docB = createMockDoc(FILE_B, { content: docBContent }); 106 | const docC = createMockDoc(FILE_C, { content: docCContent }); 107 | const linkToB: DocLink = { 108 | filePath: FILE_B, 109 | isInline: false, 110 | anchorText: "Link to B", 111 | rawLinkTarget: "./subdir/docB.md?md-link=true", 112 | }; 113 | const linkToC: DocLink = { 114 | filePath: FILE_C, 115 | isInline: false, 116 | anchorText: "Link to C", 117 | rawLinkTarget: "./docC.md?md-link=true", 118 | }; 119 | 120 | mockFileSystemService.findFiles.mockResolvedValue([FILE_A]); // Start discovery with A 121 | mockFileSystemService.readFile.mockImplementation(async (path) => { 122 | if (path === FILE_A) return docAContent; 123 | if (path === FILE_B) return docBContent; 124 | if (path === FILE_C) return docCContent; 125 | throw new Error(`Unexpected readFile call: ${path}`); 126 | }); 127 | mockDocParserService.parse.mockImplementation((filePath, content) => { 128 | if (filePath === FILE_A) return { ...docA, content }; // Return a copy 129 | if (filePath === FILE_B) return { ...docB, content }; 130 | if (filePath === FILE_C) return { ...docC, content }; 131 | throw new Error(`Unexpected parse call: ${filePath}`); 132 | }); 133 | mockLinkExtractorService.extractLinks.mockImplementation((filePath, content) => { 134 | if (filePath === FILE_A) return [linkToB]; 135 | if (filePath === FILE_B) return [linkToC]; 136 | if (filePath === FILE_C) return []; 137 | return []; 138 | }); 139 | // Ensure path resolution works for subdirectories 140 | mockFileSystemService.resolvePath.mockImplementation((baseDir, relativePath) => { 141 | // Simplified for test: handles './subdir/docB.md' from '/project' and './docC.md' from '/project/subdir' 142 | if (baseDir === "/project" && relativePath === "./subdir/docB.md") return FILE_B; 143 | if (baseDir === "/project/subdir" && relativePath === "./docC.md") return FILE_C; 144 | // Add more specific cases if needed, or use a more robust mock 145 | return `${baseDir}/${relativePath.replace("./", "")}`; // Fallback basic join 146 | }); 147 | mockFileSystemService.getDirname.mockImplementation((filePath) => { 148 | if (filePath === FILE_A) return "/project"; 149 | if (filePath === FILE_B || filePath === FILE_C) return "/project/subdir"; 150 | return filePath.substring(0, filePath.lastIndexOf("/")); 151 | }); 152 | 153 | const index = await docIndexService.buildIndex(); 154 | 155 | expect(mockFileSystemService.findFiles).toHaveBeenCalledTimes(1); 156 | expect(mockFileSystemService.readFile).toHaveBeenCalledTimes(3); // A, B, C read once 157 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_A); 158 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_B); 159 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_C); 160 | expect(mockDocParserService.parse).toHaveBeenCalledTimes(3); // A, B, C parsed once 161 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_A, docAContent); 162 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_B, docBContent); 163 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_C, docCContent); 164 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledTimes(3); // Links extracted from A, B, C 165 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledWith(FILE_A, docAContent); 166 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledWith(FILE_B, docBContent); 167 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledWith(FILE_C, docCContent); 168 | 169 | expect(index.size).toBe(3); 170 | expect(index.get(FILE_A)).toBeDefined(); 171 | expect(index.get(FILE_B)).toBeDefined(); 172 | expect(index.get(FILE_C)).toBeDefined(); 173 | 174 | // Check the links *after* buildIndex has updated them 175 | const finalDocA = index.get(FILE_A)!; 176 | const finalDocB = index.get(FILE_B)!; 177 | const finalDocC = index.get(FILE_C)!; 178 | 179 | expect(finalDocA.linksTo).toEqual([linkToB]); 180 | expect(finalDocB.linksTo).toEqual([linkToC]); 181 | expect(finalDocC.linksTo).toEqual([]); 182 | }); 183 | 184 | it("should build an index resolving a single link", async () => { 185 | const docAContent = "# Doc A\n[Link to B](./docB.md?md-link=true)"; 186 | const docBContent = "# Doc B"; 187 | const docA = createMockDoc(FILE_A, { content: docAContent }); 188 | const docB = createMockDoc(FILE_B, { content: docBContent }); 189 | const linkToB: DocLink = { 190 | filePath: FILE_B, 191 | isInline: false, 192 | anchorText: "Link to B", 193 | rawLinkTarget: "./docB.md?md-link=true", 194 | }; 195 | 196 | mockFileSystemService.findFiles.mockResolvedValue([FILE_A]); 197 | mockFileSystemService.readFile.mockImplementation(async (path) => { 198 | if (path === FILE_A) return docAContent; 199 | if (path === FILE_B) return docBContent; 200 | throw new Error(`Unexpected readFile call: ${path}`); 201 | }); 202 | mockDocParserService.parse.mockImplementation((filePath, content) => { 203 | if (filePath === FILE_A) return { ...docA, content }; // Return a copy 204 | if (filePath === FILE_B) return { ...docB, content }; 205 | throw new Error(`Unexpected parse call: ${filePath}`); 206 | }); 207 | mockLinkExtractorService.extractLinks.mockImplementation((filePath, content) => { 208 | if (filePath === FILE_A) return [linkToB]; 209 | if (filePath === FILE_B) return []; // Doc B has no links 210 | return []; 211 | }); 212 | 213 | const index = await docIndexService.buildIndex(); 214 | 215 | expect(mockFileSystemService.findFiles).toHaveBeenCalledTimes(1); 216 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_A); 217 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_B); 218 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_A, docAContent); 219 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_B, docBContent); 220 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledWith(FILE_A, docAContent); 221 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledWith(FILE_B, docBContent); 222 | 223 | expect(index.size).toBe(2); 224 | expect(index.get(FILE_A)).toBeDefined(); 225 | expect(index.get(FILE_B)).toBeDefined(); 226 | // Check the links *after* buildIndex has updated them 227 | const finalDocA = index.get(FILE_A)!; 228 | const finalDocB = index.get(FILE_B)!; 229 | expect(finalDocA.linksTo).toEqual([linkToB]); 230 | expect(finalDocB.linksTo).toEqual([]); 231 | }); 232 | 233 | it("should handle circular dependencies without infinite looping", async () => { 234 | const docAContent = "# Doc A\n[Link to B](./docB.md?md-link=true)"; 235 | const docBContent = "# Doc B\n[Link to A](./docA.md?md-link=true)"; 236 | const docA = createMockDoc(FILE_A, { content: docAContent }); 237 | const docB = createMockDoc(FILE_B, { content: docBContent }); 238 | const linkToB: DocLink = { 239 | filePath: FILE_B, 240 | isInline: false, 241 | anchorText: "Link to B", 242 | rawLinkTarget: "./docB.md?md-link=true", 243 | }; 244 | const linkToA: DocLink = { 245 | filePath: FILE_A, 246 | isInline: false, 247 | anchorText: "Link to A", 248 | rawLinkTarget: "./docA.md?md-link=true", 249 | }; 250 | 251 | mockFileSystemService.findFiles.mockResolvedValue([FILE_A]); // Start with A 252 | mockFileSystemService.readFile.mockImplementation(async (path) => { 253 | if (path === FILE_A) return docAContent; 254 | if (path === FILE_B) return docBContent; 255 | throw new Error(`Unexpected readFile call: ${path}`); 256 | }); 257 | mockDocParserService.parse.mockImplementation((filePath, content) => { 258 | if (filePath === FILE_A) return { ...docA, content }; 259 | if (filePath === FILE_B) return { ...docB, content }; 260 | throw new Error(`Unexpected parse call: ${filePath}`); 261 | }); 262 | mockLinkExtractorService.extractLinks.mockImplementation((filePath) => { 263 | if (filePath === FILE_A) return [linkToB]; 264 | if (filePath === FILE_B) return [linkToA]; 265 | return []; 266 | }); 267 | 268 | const index = await docIndexService.buildIndex(); 269 | 270 | expect(index.size).toBe(2); 271 | expect(mockFileSystemService.readFile).toHaveBeenCalledTimes(2); // A and B read once 272 | expect(mockDocParserService.parse).toHaveBeenCalledTimes(2); // A and B parsed once 273 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledTimes(2); // Links extracted from A and B 274 | 275 | const finalDocA = index.get(FILE_A)!; 276 | const finalDocB = index.get(FILE_B)!; 277 | expect(finalDocA.linksTo).toEqual([linkToB]); 278 | expect(finalDocB.linksTo).toEqual([linkToA]); 279 | }); 280 | 281 | it("should handle links to non-markdown files", async () => { 282 | const docAContent = "# Doc A\n[Config](./config.json?md-link=true)"; 283 | const jsonContent = JSON.stringify({ key: "value" }); 284 | const docA = createMockDoc(FILE_A, { content: docAContent }); 285 | const jsonDoc = createMockDoc(FILE_JSON, { content: jsonContent, isMarkdown: false }); 286 | const linkToJson: DocLink = { 287 | filePath: FILE_JSON, 288 | isInline: false, 289 | anchorText: "Config", 290 | rawLinkTarget: "./config.json?md-link=true", 291 | }; 292 | 293 | mockFileSystemService.findFiles.mockResolvedValue([FILE_A]); 294 | mockFileSystemService.readFile.mockImplementation(async (path) => { 295 | if (path === FILE_A) return docAContent; 296 | if (path === FILE_JSON) return jsonContent; 297 | throw new Error(`Unexpected readFile call: ${path}`); 298 | }); 299 | mockDocParserService.parse.mockImplementation((filePath, content) => { 300 | if (filePath === FILE_A) return { ...docA, content }; 301 | throw new Error(`Parse should not be called for non-markdown: ${filePath}`); 302 | }); 303 | // getBlankDoc will be used for the JSON 304 | mockDocParserService.getBlankDoc.mockImplementation((filePath, docOverride) => { 305 | if (filePath === FILE_JSON) 306 | return { 307 | ...jsonDoc, 308 | content: docOverride?.content ?? "", 309 | isError: docOverride?.isError ?? false, 310 | errorReason: docOverride?.errorReason, 311 | }; 312 | 313 | // Allow default mock for others if needed, though parse should handle docA 314 | return createMockDoc(filePath, { 315 | content: docOverride?.content ?? "", 316 | linksTo: [], 317 | isMarkdown: mockDocParserService.isMarkdown(filePath), 318 | isError: docOverride?.isError ?? false, 319 | errorReason: docOverride?.errorReason, 320 | }); 321 | }); 322 | 323 | mockLinkExtractorService.extractLinks.mockImplementation((filePath) => { 324 | if (filePath === FILE_A) return [linkToJson]; 325 | // Should not be called for config.json 326 | if (filePath === FILE_JSON) throw new Error("ExtractLinks called on non-markdown"); 327 | return []; 328 | }); 329 | // Ensure isMarkdown returns correctly 330 | mockDocParserService.isMarkdown.mockImplementation((fp) => fp.endsWith(".md")); 331 | 332 | const index = await docIndexService.buildIndex(); 333 | 334 | expect(index.size).toBe(2); 335 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_A); 336 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_JSON); 337 | expect(mockDocParserService.parse).toHaveBeenCalledTimes(1); // Only for Doc A 338 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_A, docAContent); 339 | expect(mockDocParserService.getBlankDoc).toHaveBeenCalledWith(FILE_JSON, { 340 | content: jsonContent, 341 | isMarkdown: false, 342 | }); // Called internally by getDoc for non-markdown 343 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledTimes(1); // Only for Doc A 344 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledWith(FILE_A, docAContent); 345 | 346 | const finalDocA = index.get(FILE_A)!; 347 | const finaljsonDoc = index.get(FILE_JSON)!; 348 | expect(finalDocA.linksTo).toEqual([linkToJson]); 349 | expect(finaljsonDoc.isMarkdown).toBe(false); 350 | expect(finaljsonDoc.linksTo).toEqual([]); // No links extracted from non-markdown 351 | }); 352 | 353 | it("should create an error doc if readFile fails", async () => { 354 | const readError = new Error("File not found"); 355 | mockFileSystemService.findFiles.mockResolvedValue([FILE_A]); 356 | mockFileSystemService.readFile.mockRejectedValue(readError); // Simulate file read error 357 | // getBlankDoc will be used to create the error placeholder 358 | mockDocParserService.getBlankDoc.mockImplementation((filePath, docOverride) => 359 | createMockDoc(filePath, { 360 | content: docOverride?.content ?? "", 361 | linksTo: [], 362 | isMarkdown: mockDocParserService.isMarkdown(filePath), 363 | isError: docOverride?.isError ?? false, 364 | errorReason: docOverride?.errorReason, 365 | }) 366 | ); 367 | 368 | const index = await docIndexService.buildIndex(); 369 | 370 | expect(index.size).toBe(1); 371 | const errorDoc = index.get(FILE_A); 372 | expect(errorDoc).toBeDefined(); 373 | expect(errorDoc?.isError).toBe(true); 374 | expect(errorDoc?.errorReason).toContain("File not found"); 375 | expect(errorDoc?.content).toContain(""); 376 | expect(mockDocParserService.parse).not.toHaveBeenCalled(); // Should not attempt parse 377 | expect(mockLinkExtractorService.extractLinks).not.toHaveBeenCalled(); // Should not extract links 378 | expect(mockDocParserService.getBlankDoc).toHaveBeenCalledWith(FILE_A, { 379 | isMarkdown: true, 380 | isError: true, 381 | errorReason: `Error loading content: ${readError.message}`, 382 | }); 383 | }); 384 | 385 | it("should create an error doc if parse fails", async () => { 386 | const docAContent = "-\nInvalid YAML\n-\n# Doc A"; // Malformed front matter perhaps 387 | const parseError = new Error("YAML parse error"); 388 | mockFileSystemService.findFiles.mockResolvedValue([FILE_A]); 389 | mockFileSystemService.readFile.mockResolvedValue(docAContent); 390 | mockDocParserService.parse.mockImplementation((filePath, content) => { 391 | // Simulate the behavior of DocParserService: return a Doc with isError true 392 | const errorDoc = createMockDoc(filePath, { 393 | content, 394 | linksTo: [], 395 | isError: true, 396 | isMarkdown: true, 397 | }); 398 | errorDoc.errorReason = `Failed to parse doc meta YAML: ${parseError.message}`; 399 | return errorDoc; 400 | }); 401 | 402 | const index = await docIndexService.buildIndex(); 403 | 404 | expect(index.size).toBe(1); 405 | const errorDoc = index.get(FILE_A); 406 | expect(errorDoc).toBeDefined(); 407 | expect(errorDoc?.isError).toBe(true); 408 | expect(errorDoc?.errorReason).toContain(parseError.message); 409 | expect(errorDoc?.content).toBe(docAContent); // Content should still be there as per DocParserService logic 410 | expect(mockLinkExtractorService.extractLinks).not.toHaveBeenCalled(); // Should not extract links from error doc 411 | }); 412 | 413 | it("should reuse existing docs from the map instead of re-reading/parsing", async () => { 414 | // Scenario: A -> B, C -> B. FindFiles returns A and C. 415 | const docAContent = "# Doc A\n[Link to B](./docB.md?md-link=true)"; 416 | const docBContent = "# Doc B"; 417 | const docCContent = "# Doc C\n[Link to B](./docB.md?md-link=true)"; 418 | const docA = createMockDoc(FILE_A, { content: docAContent }); 419 | const docB = createMockDoc(FILE_B, { content: docBContent }); 420 | const docC = createMockDoc(FILE_C, { content: docCContent }); 421 | const linkToBFromA: DocLink = { 422 | filePath: FILE_B, 423 | isInline: false, 424 | anchorText: "Link to B", 425 | rawLinkTarget: "./docB.md?md-link=true", 426 | }; 427 | const linkToBFromC: DocLink = { 428 | filePath: FILE_B, 429 | isInline: false, 430 | anchorText: "Link to B", 431 | rawLinkTarget: "./docB.md?md-link=true", 432 | }; 433 | 434 | mockFileSystemService.findFiles.mockResolvedValue([FILE_A, FILE_C]); // Start with A and C 435 | mockFileSystemService.readFile.mockImplementation(async (path) => { 436 | if (path === FILE_A) return docAContent; 437 | if (path === FILE_B) return docBContent; 438 | if (path === FILE_C) return docCContent; 439 | throw new Error(`Unexpected readFile call: ${path}`); 440 | }); 441 | mockDocParserService.parse.mockImplementation((filePath, content) => { 442 | if (filePath === FILE_A) return { ...docA, content }; 443 | if (filePath === FILE_B) return { ...docB, content }; 444 | if (filePath === FILE_C) return { ...docC, content }; 445 | throw new Error(`Unexpected parse call: ${filePath}`); 446 | }); 447 | mockLinkExtractorService.extractLinks.mockImplementation((filePath) => { 448 | if (filePath === FILE_A) return [linkToBFromA]; 449 | if (filePath === FILE_C) return [linkToBFromC]; 450 | if (filePath === FILE_B) return []; // B has no links 451 | return []; 452 | }); 453 | 454 | const index = await docIndexService.buildIndex(); 455 | 456 | expect(index.size).toBe(3); 457 | expect(mockFileSystemService.readFile).toHaveBeenCalledTimes(3); // A, C (initial), B (discovered) 458 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_A); 459 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_C); 460 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_B); 461 | expect(mockDocParserService.parse).toHaveBeenCalledTimes(3); // A, C, B parsed once each 462 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_A, docAContent); 463 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_C, docCContent); 464 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_B, docBContent); 465 | expect(mockLinkExtractorService.extractLinks).toHaveBeenCalledTimes(3); // Links extracted from A, C, B 466 | 467 | const finalDocA = index.get(FILE_A)!; 468 | const finalDocC = index.get(FILE_C)!; 469 | const finalDocB = index.get(FILE_B)!; 470 | expect(finalDocA.linksTo).toEqual([linkToBFromA]); 471 | expect(finalDocC.linksTo).toEqual([linkToBFromC]); 472 | expect(finalDocB.linksTo).toEqual([]); 473 | }); 474 | 475 | describe("getDoc", () => { 476 | it("should read and parse a doc if not in cache", async () => { 477 | const docAContent = "# Doc A"; 478 | const docA = createMockDoc(FILE_A, { content: docAContent }); 479 | mockFileSystemService.readFile.mockResolvedValue(docAContent); 480 | mockDocParserService.parse.mockReturnValue(docA); 481 | 482 | const result = await docIndexService.getDoc(FILE_A); 483 | 484 | expect(result).toEqual(docA); 485 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_A); 486 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_A, docAContent); 487 | // Check internal map state (implementation detail, but useful here) 488 | // @ts-expect-error - Accessing private member for test verification 489 | const internalMap = docIndexService.docMap as DocIndex; 490 | expect(internalMap.has(FILE_A)).toBe(true); 491 | expect(internalMap.get(FILE_A)).toEqual(docA); 492 | }); 493 | 494 | it("should return doc from cache if already loaded", async () => { 495 | const docAContent = "# Doc A"; 496 | const docA = createMockDoc(FILE_A, { content: docAContent }); 497 | mockFileSystemService.readFile.mockResolvedValue(docAContent); 498 | mockDocParserService.parse.mockReturnValue(docA); 499 | 500 | // Load it the first time 501 | await docIndexService.getDoc(FILE_A); 502 | 503 | // Reset call counts 504 | mockFileSystemService.readFile.mockClear(); 505 | mockDocParserService.parse.mockClear(); 506 | 507 | // Get it the second time 508 | const result = await docIndexService.getDoc(FILE_A); 509 | 510 | expect(result).toEqual(docA); 511 | expect(mockFileSystemService.readFile).not.toHaveBeenCalled(); 512 | expect(mockDocParserService.parse).not.toHaveBeenCalled(); 513 | }); 514 | 515 | it("should return an error doc if readFile fails", async () => { 516 | const readError = new Error("Cannot read"); 517 | mockFileSystemService.readFile.mockRejectedValue(readError); 518 | mockDocParserService.getBlankDoc.mockImplementation((filePath, docOverride) => 519 | createMockDoc(filePath, { 520 | content: docOverride?.content ?? "", 521 | linksTo: [], 522 | isMarkdown: mockDocParserService.isMarkdown(filePath), 523 | isError: docOverride?.isError ?? false, 524 | errorReason: docOverride?.errorReason, 525 | }) 526 | ); 527 | 528 | const result = await docIndexService.getDoc(FILE_A); 529 | 530 | expect(result.isError).toBe(true); 531 | expect(result.filePath).toBe(FILE_A); 532 | expect(result.errorReason).toContain(readError.message); 533 | expect(mockDocParserService.parse).not.toHaveBeenCalled(); 534 | expect(mockDocParserService.getBlankDoc).toHaveBeenCalledWith(FILE_A, { 535 | isMarkdown: true, 536 | isError: true, 537 | errorReason: `Error loading content: ${readError.message}`, 538 | }); 539 | }); 540 | }); 541 | 542 | describe("getDocs", () => { 543 | it("should fetch multiple docs, utilizing cache", async () => { 544 | const docAContent = "# Doc A"; 545 | const docBContent = "# Doc B"; 546 | const docA = createMockDoc(FILE_A, { content: docAContent }); 547 | const docB = createMockDoc(FILE_B, { content: docBContent }); 548 | 549 | // Pre-load Doc A into cache 550 | mockFileSystemService.readFile.mockResolvedValueOnce(docAContent); 551 | mockDocParserService.parse.mockReturnValueOnce(docA); 552 | await docIndexService.getDoc(FILE_A); 553 | 554 | // Reset mocks for the getDocs call 555 | mockFileSystemService.readFile.mockClear(); 556 | mockDocParserService.parse.mockClear(); 557 | mockFileSystemService.readFile.mockResolvedValueOnce(docBContent); // For Doc B 558 | mockDocParserService.parse.mockReturnValueOnce(docB); // For Doc B 559 | 560 | const results = await docIndexService.getDocs([FILE_A, FILE_B, FILE_A]); // Request A, B, and A again 561 | 562 | expect(results).toHaveLength(2); // Duplicates should be handled 563 | expect(results).toEqual(expect.arrayContaining([docA, docB])); 564 | expect(mockFileSystemService.readFile).toHaveBeenCalledTimes(1); // Only called for B 565 | expect(mockFileSystemService.readFile).toHaveBeenCalledWith(FILE_B); 566 | expect(mockDocParserService.parse).toHaveBeenCalledTimes(1); // Only called for B 567 | expect(mockDocParserService.parse).toHaveBeenCalledWith(FILE_B, docBContent); 568 | }); 569 | }); 570 | 571 | describe("getAgentAttachableDocs", () => { 572 | it("should return all markdown docs that are not global and have no auto-attachment globs", () => { 573 | const docA = createMockDoc(FILE_A, { 574 | content: "# Doc A", 575 | meta: { description: "Doc A", alwaysApply: false, globs: [] }, 576 | }); 577 | const docB = createMockDoc(FILE_B, { 578 | content: "# Doc B", 579 | meta: { description: "Doc B", alwaysApply: true, globs: [] }, 580 | }); 581 | const docC = createMockDoc(FILE_C, { 582 | content: "# Doc C", 583 | meta: { description: "Doc C", alwaysApply: false, globs: ["*.md"] }, 584 | }); 585 | const docD = createMockDoc(FILE_D, { 586 | content: "# Doc D", 587 | meta: { description: "Doc D", alwaysApply: false, globs: [] }, 588 | }); 589 | const docE = createMockDoc(FILE_E, { 590 | content: "# Doc E", 591 | meta: { description: "Doc E", alwaysApply: false, globs: ["*.md"] }, 592 | }); 593 | 594 | const docs = [docA, docB, docC, docD, docE]; 595 | const index = new DocIndexService( 596 | mockConfig, 597 | mockFileSystemService, 598 | mockDocParserService, 599 | mockLinkExtractorService 600 | ); 601 | index.setDocs(docs); 602 | 603 | const result = index.getAgentAttachableDocs(); 604 | 605 | expect(result).toEqual([docA, docD]); 606 | }); 607 | }); 608 | }); 609 | -------------------------------------------------------------------------------- /src/tests/link-extractor.service.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi, beforeEach, Mocked } from "vitest"; 2 | import { LinkExtractorService } from "../link-extractor.service.js"; 3 | import { IFileSystemService, DocLink } from "../types.js"; 4 | import { unwrapMock } from "../../setup.tests.js"; 5 | import { createMockFileSystemService } from "./__mocks__/file-system.service.mock.js"; 6 | 7 | vi.mock("./file-system.service.js"); 8 | describe("LinkExtractorService", () => { 9 | let mockFileSystemService: Mocked; 10 | let linkExtractorService: LinkExtractorService; 11 | 12 | const DOC_FILE_PATH = "/path/to/source/doc.md"; 13 | const SOURCE_DIR = "/path/to/source"; 14 | 15 | beforeEach(() => { 16 | vi.resetAllMocks(); 17 | 18 | mockFileSystemService = createMockFileSystemService(); 19 | 20 | mockFileSystemService.getDirname.mockReturnValue(SOURCE_DIR); 21 | mockFileSystemService.resolvePath.mockImplementation((base, rel) => `${base}/${rel}`); 22 | linkExtractorService = new LinkExtractorService(unwrapMock(mockFileSystemService)); 23 | }); 24 | 25 | it("should be defined", () => { 26 | expect(linkExtractorService).toBeDefined(); 27 | }); 28 | 29 | it("should extract a single markdown link with ?md-link=true", () => { 30 | const content = "Some text [link text](./relative/link.md?md-link=true) more text."; 31 | const relativeLink = "./relative/link.md"; 32 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 33 | 34 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 35 | 36 | expect(mockFileSystemService.getDirname).toHaveBeenCalledWith(DOC_FILE_PATH); 37 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 38 | expect(links).toHaveLength(1); 39 | const expectedDocLinks: DocLink[] = [ 40 | { 41 | filePath: expectedAbsolutePath, 42 | rawLinkTarget: "./relative/link.md?md-link=true", 43 | isInline: false, 44 | inlineLinesRange: undefined, 45 | anchorText: "link text", 46 | }, 47 | ]; 48 | expect(links).toEqual(expectedDocLinks); 49 | }); 50 | 51 | it("should extract a single markdown link with ?md-link=1", () => { 52 | const content = "Some text [link text](./relative/link.md?md-link=1) more text."; 53 | const relativeLink = "./relative/link.md"; 54 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 55 | 56 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 57 | 58 | expect(links).toHaveLength(1); 59 | const expectedDocLinks: DocLink[] = [ 60 | { 61 | filePath: expectedAbsolutePath, 62 | rawLinkTarget: "./relative/link.md?md-link=1", 63 | isInline: false, 64 | inlineLinesRange: undefined, 65 | anchorText: "link text", 66 | }, 67 | ]; 68 | expect(links).toEqual(expectedDocLinks); 69 | }); 70 | 71 | it("should extract multiple markdown links with ?md-link=true and ?md-link=1", () => { 72 | const content = ` 73 | First link: [link1](../link1.md?md-link=true) 74 | Second link: [link2](./folder/link2.md?md-link=true&other=param) 75 | Third link: [link3](./folder/link3.md?md-link=1&other=param) 76 | Some other text. 77 | `; 78 | const relativeLink1 = "../link1.md"; 79 | const relativeLink2 = "./folder/link2.md"; 80 | const relativeLink3 = "./folder/link3.md"; 81 | const expectedAbsolutePath1 = `${SOURCE_DIR}/${relativeLink1}`; 82 | const expectedAbsolutePath2 = `${SOURCE_DIR}/${relativeLink2}`; 83 | const expectedAbsolutePath3 = `${SOURCE_DIR}/${relativeLink3}`; 84 | 85 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 86 | 87 | expect(mockFileSystemService.getDirname).toHaveBeenCalledWith(DOC_FILE_PATH); 88 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink1); 89 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink2); 90 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink3); 91 | expect(links).toHaveLength(3); 92 | const expectedDocLinks: DocLink[] = [ 93 | { 94 | filePath: expectedAbsolutePath1, 95 | rawLinkTarget: "../link1.md?md-link=true", 96 | isInline: false, 97 | inlineLinesRange: undefined, 98 | anchorText: "link1", 99 | }, 100 | { 101 | filePath: expectedAbsolutePath2, 102 | rawLinkTarget: "./folder/link2.md?md-link=true&other=param", 103 | isInline: false, 104 | inlineLinesRange: undefined, 105 | anchorText: "link2", 106 | }, 107 | { 108 | filePath: expectedAbsolutePath3, 109 | rawLinkTarget: "./folder/link3.md?md-link=1&other=param", 110 | isInline: false, 111 | inlineLinesRange: undefined, 112 | anchorText: "link3", 113 | }, 114 | ]; 115 | expect(links).toEqual(expectedDocLinks); 116 | }); 117 | 118 | it("should ignore links without ?md-link=true or ?md-link=1", () => { 119 | const content = ` 120 | [link1](./link1.md) 121 | [link2](./link2.md?md-link=false) 122 | [link3](./link3.md?something=else) 123 | `; 124 | 125 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 126 | 127 | expect(links).toHaveLength(0); 128 | expect(mockFileSystemService.resolvePath).not.toHaveBeenCalled(); 129 | }); 130 | 131 | it("should handle links with HTML entities like &", () => { 132 | const content = "Link: [encoded link](./path/to/file&stuff.md?md-link=true)"; 133 | const decodedRelativeLink = "./path/to/file&stuff.md"; 134 | const expectedAbsolutePath = `${SOURCE_DIR}/${decodedRelativeLink}`; 135 | 136 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 137 | 138 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, decodedRelativeLink); 139 | expect(links).toHaveLength(1); 140 | const expectedDocLinks: DocLink[] = [ 141 | { 142 | filePath: expectedAbsolutePath, 143 | rawLinkTarget: "./path/to/file&stuff.md?md-link=true", 144 | isInline: false, 145 | inlineLinesRange: undefined, 146 | anchorText: "encoded link", 147 | }, 148 | ]; 149 | expect(links).toEqual(expectedDocLinks); 150 | }); 151 | 152 | it("should return an empty array if no markdown links are found", () => { 153 | const content = "This document has no markdown links."; 154 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 155 | expect(links).toEqual([]); 156 | expect(mockFileSystemService.resolvePath).not.toHaveBeenCalled(); 157 | }); 158 | 159 | it("should handle path resolution errors gracefully", () => { 160 | const content = ` 161 | [Valid Link](./valid.md?md-link=true) 162 | [Link Causing Resolution Error](./error-path.md?md-link=true) 163 | `; 164 | const validRelative = "./valid.md"; 165 | const errorRelative = "./error-path.md"; 166 | const validAbsolute = `${SOURCE_DIR}/${validRelative}`; 167 | const resolutionError = new Error("Cannot resolve path"); 168 | 169 | mockFileSystemService.resolvePath.mockImplementation((base, rel) => { 170 | if (rel === validRelative) return `${base}/${rel}`; 171 | if (rel === errorRelative) throw resolutionError; 172 | throw new Error(`Unexpected path resolution call: ${base}, ${rel}`); 173 | }); 174 | 175 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 176 | 177 | expect(links).toHaveLength(1); 178 | const expectedDocLinks: DocLink[] = [ 179 | { 180 | filePath: validAbsolute, 181 | rawLinkTarget: "./valid.md?md-link=true", 182 | isInline: false, 183 | inlineLinesRange: undefined, 184 | anchorText: "Valid Link", 185 | }, 186 | ]; 187 | expect(links).toEqual(expectedDocLinks); 188 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, validRelative); 189 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, errorRelative); 190 | }); 191 | 192 | it("should correctly resolve paths relative to the source document's directory", () => { 193 | const content = "[link](./relative/path/doc.md?md-link=true)"; 194 | const relativeLink = "./relative/path/doc.md"; 195 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 196 | 197 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 198 | 199 | expect(mockFileSystemService.getDirname).toHaveBeenCalledWith(DOC_FILE_PATH); 200 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 201 | const expectedDocLinks: DocLink[] = [ 202 | { 203 | filePath: expectedAbsolutePath, 204 | rawLinkTarget: "./relative/path/doc.md?md-link=true", 205 | isInline: false, 206 | inlineLinesRange: undefined, 207 | anchorText: "link", 208 | }, 209 | ]; 210 | expect(links).toEqual(expectedDocLinks); 211 | }); 212 | 213 | it("should correctly resolve absolute paths relative to the source document's directory (using resolvePath)", () => { 214 | const content = "[link](/absolute/path/doc.md?md-link=true)"; 215 | const absoluteLinkPath = "/absolute/path/doc.md"; 216 | const expectedResolvedPath = `${SOURCE_DIR}/${absoluteLinkPath}`; 217 | 218 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 219 | 220 | expect(mockFileSystemService.getDirname).toHaveBeenCalledWith(DOC_FILE_PATH); 221 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, absoluteLinkPath); 222 | const expectedDocLinks: DocLink[] = [ 223 | { 224 | filePath: expectedResolvedPath, 225 | rawLinkTarget: "/absolute/path/doc.md?md-link=true", 226 | isInline: false, 227 | inlineLinesRange: undefined, 228 | anchorText: "link", 229 | }, 230 | ]; 231 | expect(links).toEqual(expectedDocLinks); 232 | }); 233 | 234 | it("should extract an inline link with ?md-link=true&md-embed=true", () => { 235 | const content = "[inline link](./inline.md?md-link=true&md-embed=true)"; 236 | const relativeLink = "./inline.md"; 237 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 238 | 239 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 240 | 241 | expect(links).toHaveLength(1); 242 | const expectedDocLinks: DocLink[] = [ 243 | { 244 | filePath: expectedAbsolutePath, 245 | rawLinkTarget: "./inline.md?md-link=true&md-embed=true", 246 | isInline: true, 247 | inlineLinesRange: undefined, 248 | anchorText: "inline link", 249 | }, 250 | ]; 251 | expect(links).toEqual(expectedDocLinks); 252 | }); 253 | 254 | it("should extract an inline link with ?md-link=1&md-embed=1", () => { 255 | const content = "[inline link](./inline.md?md-link=1&md-embed=1)"; 256 | const relativeLink = "./inline.md"; 257 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 258 | 259 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 260 | 261 | expect(links).toHaveLength(1); 262 | const expectedDocLinks: DocLink[] = [ 263 | { 264 | filePath: expectedAbsolutePath, 265 | rawLinkTarget: "./inline.md?md-link=1&md-embed=1", 266 | isInline: true, 267 | inlineLinesRange: undefined, 268 | anchorText: "inline link", 269 | }, 270 | ]; 271 | expect(links).toEqual(expectedDocLinks); 272 | }); 273 | 274 | it("should extract an inline link with specific lines range ?lines=45-100", () => { 275 | const content = "[inline link](./inline.md?md-link=true&md-embed=45-100)"; 276 | const relativeLink = "./inline.md"; 277 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 278 | 279 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 280 | 281 | expect(links).toHaveLength(1); 282 | const expectedDocLinks: DocLink[] = [ 283 | { 284 | filePath: expectedAbsolutePath, 285 | rawLinkTarget: "./inline.md?md-link=true&md-embed=45-100", 286 | isInline: true, 287 | inlineLinesRange: { from: 44, to: 99 }, 288 | anchorText: "inline link", 289 | }, 290 | ]; 291 | expect(links).toEqual(expectedDocLinks); 292 | }); 293 | 294 | it("should extract an inline link with lines range starting from 0 ?lines=-100", () => { 295 | const content = "[inline link](./inline.md?md-link=true&md-embed=-100)"; 296 | const relativeLink = "./inline.md"; 297 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 298 | 299 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 300 | 301 | expect(links).toHaveLength(1); 302 | const expectedDocLinks: DocLink[] = [ 303 | { 304 | filePath: expectedAbsolutePath, 305 | rawLinkTarget: "./inline.md?md-link=true&md-embed=-100", 306 | isInline: true, 307 | inlineLinesRange: { from: 0, to: 99 }, 308 | anchorText: "inline link", 309 | }, 310 | ]; 311 | expect(links).toEqual(expectedDocLinks); 312 | }); 313 | 314 | it("should extract an inline link with lines range ending at 'end' ?lines=34-", () => { 315 | const content = "[inline link](./inline.md?md-link=true&md-embed=34-)"; 316 | const relativeLink = "./inline.md"; 317 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 318 | 319 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 320 | 321 | expect(links).toHaveLength(1); 322 | const expectedDocLinks: DocLink[] = [ 323 | { 324 | filePath: expectedAbsolutePath, 325 | rawLinkTarget: "./inline.md?md-link=true&md-embed=34-", 326 | isInline: true, 327 | inlineLinesRange: { from: 33, to: "end" }, 328 | anchorText: "inline link", 329 | }, 330 | ]; 331 | expect(links).toEqual(expectedDocLinks); 332 | }); 333 | 334 | it("should extract an inline link with lines range ending at 'end' ?lines=34-end", () => { 335 | const content = "[inline link](./inline.md?md-link=true&md-embed=34-end)"; 336 | const relativeLink = "./inline.md"; 337 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 338 | 339 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 340 | 341 | expect(links).toHaveLength(1); 342 | const expectedDocLinks: DocLink[] = [ 343 | { 344 | filePath: expectedAbsolutePath, 345 | rawLinkTarget: "./inline.md?md-link=true&md-embed=34-end", 346 | isInline: true, 347 | inlineLinesRange: { from: 33, to: "end" }, 348 | anchorText: "inline link", 349 | }, 350 | ]; 351 | expect(links).toEqual(expectedDocLinks); 352 | }); 353 | 354 | it("should ignore lines parameter if inline is not true", () => { 355 | const content = "[link](./doc.md?md-link=true&mdr-lines=10-20)"; 356 | const relativeLink = "./doc.md"; 357 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 358 | 359 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 360 | 361 | expect(links).toHaveLength(1); 362 | const expectedDocLinks: DocLink[] = [ 363 | { 364 | filePath: expectedAbsolutePath, 365 | rawLinkTarget: "./doc.md?md-link=true&mdr-lines=10-20", 366 | isInline: false, 367 | inlineLinesRange: undefined, 368 | anchorText: "link", 369 | }, 370 | ]; 371 | expect(links).toEqual(expectedDocLinks); 372 | }); 373 | 374 | it("should ignore invalid lines format and log warning", () => { 375 | const content = "[inline link](./inline.md?md-link=true&md-embed=abc-def)"; 376 | const relativeLink = "./inline.md"; 377 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 378 | 379 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 380 | 381 | expect(links).toHaveLength(1); 382 | const expectedDocLinks: DocLink[] = [ 383 | { 384 | filePath: expectedAbsolutePath, 385 | rawLinkTarget: "./inline.md?md-link=true&md-embed=abc-def", 386 | isInline: true, 387 | inlineLinesRange: { 388 | from: 0, 389 | to: "end", 390 | }, 391 | anchorText: "inline link", 392 | }, 393 | ]; 394 | expect(links).toEqual(expectedDocLinks); 395 | }); 396 | 397 | it("should ignore lines range where start > end and log warning", () => { 398 | const content = "[inline link](./inline.md?md-link=true&md-embed=100-50)"; 399 | const relativeLink = "./inline.md"; 400 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 401 | 402 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 403 | 404 | expect(links).toHaveLength(1); 405 | const expectedDocLinks: DocLink[] = [ 406 | { 407 | filePath: expectedAbsolutePath, 408 | rawLinkTarget: "./inline.md?md-link=true&md-embed=100-50", 409 | isInline: true, 410 | inlineLinesRange: undefined, 411 | anchorText: "inline link", 412 | }, 413 | ]; 414 | expect(links).toEqual(expectedDocLinks); 415 | }); 416 | 417 | it("should ignore lines format with multiple hyphens and log warning", () => { 418 | const content = "[inline link](./inline.md?md-link=true&md-embed=10-20-30)"; 419 | const relativeLink = "./inline.md"; 420 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 421 | 422 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 423 | 424 | expect(links).toHaveLength(1); 425 | const expectedDocLinks: DocLink[] = [ 426 | { 427 | filePath: expectedAbsolutePath, 428 | rawLinkTarget: "./inline.md?md-link=true&md-embed=10-20-30", 429 | isInline: true, 430 | inlineLinesRange: undefined, 431 | anchorText: "inline link", 432 | }, 433 | ]; 434 | expect(links).toEqual(expectedDocLinks); 435 | }); 436 | 437 | it("should extract a mix of inline and non-inline links", () => { 438 | const content = ` 439 | [Normal Link](./normal.md?md-link=true) 440 | [Inline Link 1](./inline1.md?md-link=true&md-embed=10-20) 441 | [Inline Link 2](./inline2.md?md-link=1&md-embed=-5) 442 | [Ignored Link](./ignored.md) 443 | [Normal Link 2](../normal2.md?md-link=1) 444 | [Inline Link 3](./inline3.md?md-link=1&md-embed=50-) 445 | `; 446 | const relNormal = "./normal.md"; 447 | const relInline1 = "./inline1.md"; 448 | const relInline2 = "./inline2.md"; 449 | const relNormal2 = "../normal2.md"; 450 | const relInline3 = "./inline3.md"; 451 | 452 | const absNormal = `${SOURCE_DIR}/${relNormal}`; 453 | const absInline1 = `${SOURCE_DIR}/${relInline1}`; 454 | const absInline2 = `${SOURCE_DIR}/${relInline2}`; 455 | const absNormal2 = `${SOURCE_DIR}/${relNormal2}`; 456 | const absInline3 = `${SOURCE_DIR}/${relInline3}`; 457 | 458 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 459 | 460 | expect(links).toHaveLength(5); 461 | const expectedDocLinks: DocLink[] = [ 462 | { 463 | filePath: absNormal, 464 | rawLinkTarget: "./normal.md?md-link=true", 465 | isInline: false, 466 | inlineLinesRange: undefined, 467 | anchorText: "Normal Link", 468 | }, 469 | { 470 | filePath: absInline1, 471 | rawLinkTarget: "./inline1.md?md-link=true&md-embed=10-20", 472 | isInline: true, 473 | inlineLinesRange: { from: 9, to: 19 }, 474 | anchorText: "Inline Link 1", 475 | }, 476 | { 477 | filePath: absInline2, 478 | rawLinkTarget: "./inline2.md?md-link=1&md-embed=-5", 479 | isInline: true, 480 | inlineLinesRange: { from: 0, to: 4 }, 481 | anchorText: "Inline Link 2", 482 | }, 483 | { 484 | filePath: absNormal2, 485 | rawLinkTarget: "../normal2.md?md-link=1", 486 | isInline: false, 487 | inlineLinesRange: undefined, 488 | anchorText: "Normal Link 2", 489 | }, 490 | { 491 | filePath: absInline3, 492 | rawLinkTarget: "./inline3.md?md-link=1&md-embed=50-", 493 | isInline: true, 494 | inlineLinesRange: { from: 49, to: "end" }, 495 | anchorText: "Inline Link 3", 496 | }, 497 | ]; 498 | expect(links).toEqual(expect.arrayContaining(expectedDocLinks)); 499 | expect(links).not.toEqual( 500 | expect.arrayContaining([ 501 | expect.objectContaining({ filePath: expect.stringContaining("ignored.md") }), 502 | ]) 503 | ); 504 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledTimes(5); 505 | }); 506 | 507 | it("should ignore links without ?md-link=true/1 or ?md-embed!=false", () => { 508 | const content = ` 509 | [link1](./link1.md) 510 | [link2](./link2.md?md-link=false) 511 | [link3](./link3.md?something=else) 512 | [link4](./link4.md?md-embed=false) 513 | `; 514 | 515 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 516 | 517 | expect(links).toHaveLength(0); 518 | expect(mockFileSystemService.resolvePath).not.toHaveBeenCalled(); 519 | }); 520 | 521 | it("should extract an inline link with ONLY ?md-embed=true", () => { 522 | const content = "[inline link](./inline.md?md-embed=true)"; 523 | const relativeLink = "./inline.md"; 524 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 525 | 526 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 527 | 528 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 529 | expect(links).toHaveLength(1); 530 | const expectedDocLinks: DocLink[] = [ 531 | { 532 | filePath: expectedAbsolutePath, 533 | rawLinkTarget: "./inline.md?md-embed=true", 534 | isInline: true, 535 | inlineLinesRange: undefined, 536 | anchorText: "inline link", 537 | }, 538 | ]; 539 | expect(links).toEqual(expectedDocLinks); 540 | }); 541 | 542 | it("should extract an inline link with ONLY ?md-embed=1", () => { 543 | const content = "[inline link](./inline.md?md-embed=1)"; 544 | const relativeLink = "./inline.md"; 545 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 546 | 547 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 548 | 549 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 550 | expect(links).toHaveLength(1); 551 | const expectedDocLinks: DocLink[] = [ 552 | { 553 | filePath: expectedAbsolutePath, 554 | rawLinkTarget: "./inline.md?md-embed=1", 555 | isInline: true, 556 | inlineLinesRange: undefined, 557 | anchorText: "inline link", 558 | }, 559 | ]; 560 | expect(links).toEqual(expectedDocLinks); 561 | }); 562 | 563 | it("should extract an inline link with ONLY ?md-embed=", () => { 564 | const content = "[inline link range](./inline.md?md-embed=10-20)"; 565 | const relativeLink = "./inline.md"; 566 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 567 | 568 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 569 | 570 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 571 | expect(links).toHaveLength(1); 572 | const expectedDocLinks: DocLink[] = [ 573 | { 574 | filePath: expectedAbsolutePath, 575 | rawLinkTarget: "./inline.md?md-embed=10-20", 576 | isInline: true, 577 | inlineLinesRange: { from: 9, to: 19 }, 578 | anchorText: "inline link range", 579 | }, 580 | ]; 581 | expect(links).toEqual(expectedDocLinks); 582 | }); 583 | 584 | it("should extract a non-inline link when ?md-link=true and ?md-embed=false", () => { 585 | const content = "[link](./doc.md?md-link=true&md-embed=false)"; 586 | const relativeLink = "./doc.md"; 587 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 588 | 589 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 590 | 591 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 592 | expect(links).toHaveLength(1); 593 | const expectedDocLinks: DocLink[] = [ 594 | { 595 | filePath: expectedAbsolutePath, 596 | rawLinkTarget: "./doc.md?md-link=true&md-embed=false", 597 | isInline: false, 598 | inlineLinesRange: undefined, 599 | anchorText: "link", 600 | }, 601 | ]; 602 | expect(links).toEqual(expectedDocLinks); 603 | }); 604 | 605 | it("should ignore lines parameter if inline is false (md-link=true, md-embed=false)", () => { 606 | const content = "[link](./doc.md?md-link=true&md-embed=false&lines=10-20)"; 607 | const relativeLink = "./doc.md"; 608 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 609 | 610 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 611 | 612 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 613 | expect(links).toHaveLength(1); 614 | const expectedDocLinks: DocLink[] = [ 615 | { 616 | filePath: expectedAbsolutePath, 617 | rawLinkTarget: "./doc.md?md-link=true&md-embed=false&lines=10-20", 618 | isInline: false, 619 | inlineLinesRange: undefined, 620 | anchorText: "link", 621 | }, 622 | ]; 623 | expect(links).toEqual(expectedDocLinks); 624 | }); 625 | 626 | it("should ignore lines parameter if inline is not requested (only md-link=true)", () => { 627 | const content = "[link](./doc.md?md-link=true)"; 628 | const relativeLink = "./doc.md"; 629 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 630 | 631 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 632 | 633 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 634 | expect(links).toHaveLength(1); 635 | const expectedDocLinks: DocLink[] = [ 636 | { 637 | filePath: expectedAbsolutePath, 638 | rawLinkTarget: "./doc.md?md-link=true", 639 | isInline: false, 640 | inlineLinesRange: undefined, 641 | anchorText: "link", 642 | }, 643 | ]; 644 | expect(links).toEqual(expectedDocLinks); 645 | }); 646 | 647 | it("should ignore invalid lines format and log warning, embedding whole file (implicit link)", () => { 648 | const content = "[inline link](./inline.md?md-embed=abc-def)"; 649 | const relativeLink = "./inline.md"; 650 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 651 | 652 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 653 | 654 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 655 | expect(links).toHaveLength(1); 656 | const expectedDocLinks: DocLink[] = [ 657 | { 658 | filePath: expectedAbsolutePath, 659 | rawLinkTarget: "./inline.md?md-embed=abc-def", 660 | isInline: true, 661 | inlineLinesRange: { 662 | from: 0, 663 | to: "end", 664 | }, 665 | anchorText: "inline link", 666 | }, 667 | ]; 668 | expect(links).toEqual(expectedDocLinks); 669 | }); 670 | 671 | it("should ignore lines range where start > end and log warning, embedding whole file (implicit link)", () => { 672 | const content = "[inline link](./inline.md?md-embed=100-50)"; 673 | const relativeLink = "./inline.md"; 674 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 675 | 676 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 677 | 678 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 679 | expect(links).toHaveLength(1); 680 | const expectedDocLinks: DocLink[] = [ 681 | { 682 | filePath: expectedAbsolutePath, 683 | rawLinkTarget: "./inline.md?md-embed=100-50", 684 | isInline: true, 685 | inlineLinesRange: undefined, 686 | anchorText: "inline link", 687 | }, 688 | ]; 689 | expect(links).toEqual(expectedDocLinks); 690 | }); 691 | 692 | it("should ignore lines format with multiple hyphens and log warning, embedding whole file (implicit link)", () => { 693 | const content = "[inline link](./inline.md?md-embed=10-20-30)"; 694 | const relativeLink = "./inline.md"; 695 | const expectedAbsolutePath = `${SOURCE_DIR}/${relativeLink}`; 696 | 697 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 698 | 699 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledWith(SOURCE_DIR, relativeLink); 700 | expect(links).toHaveLength(1); 701 | const expectedDocLinks: DocLink[] = [ 702 | { 703 | filePath: expectedAbsolutePath, 704 | rawLinkTarget: "./inline.md?md-embed=10-20-30", 705 | isInline: true, 706 | inlineLinesRange: undefined, 707 | anchorText: "inline link", 708 | }, 709 | ]; 710 | expect(links).toEqual(expectedDocLinks); 711 | }); 712 | 713 | it("should extract a mix of inline and non-inline links with implicit linking", () => { 714 | const content = ` 715 | [Normal Link](./normal.md?md-link=true) 716 | [Inline Link 1](./inline1.md?md-embed=10-20) 717 | [Inline Link 2](./inline2.md?md-link=1&md-embed=-5) 718 | [Ignored Link](./ignored.md) 719 | [Normal Link 2](../normal2.md?md-link=1) 720 | [Inline Link 3](./inline3.md?md-embed=50-) 721 | [Ignored Link 2](./ignored2.md?md-embed=false) 722 | `; 723 | const relNormal = "./normal.md"; 724 | const relInline1 = "./inline1.md"; 725 | const relInline2 = "./inline2.md"; 726 | const relNormal2 = "../normal2.md"; 727 | const relInline3 = "./inline3.md"; 728 | 729 | const absNormal = `${SOURCE_DIR}/${relNormal}`; 730 | const absInline1 = `${SOURCE_DIR}/${relInline1}`; 731 | const absInline2 = `${SOURCE_DIR}/${relInline2}`; 732 | const absNormal2 = `${SOURCE_DIR}/${relNormal2}`; 733 | const absInline3 = `${SOURCE_DIR}/${relInline3}`; 734 | 735 | const links = linkExtractorService.extractLinks(DOC_FILE_PATH, content); 736 | 737 | expect(links).toHaveLength(5); 738 | const expectedDocLinks: DocLink[] = [ 739 | { 740 | filePath: absNormal, 741 | rawLinkTarget: "./normal.md?md-link=true", 742 | isInline: false, 743 | inlineLinesRange: undefined, 744 | anchorText: "Normal Link", 745 | }, 746 | { 747 | filePath: absInline1, 748 | rawLinkTarget: "./inline1.md?md-embed=10-20", 749 | isInline: true, 750 | inlineLinesRange: { from: 9, to: 19 }, 751 | anchorText: "Inline Link 1", 752 | }, 753 | { 754 | filePath: absInline2, 755 | rawLinkTarget: "./inline2.md?md-link=1&md-embed=-5", 756 | isInline: true, 757 | inlineLinesRange: { from: 0, to: 4 }, 758 | anchorText: "Inline Link 2", 759 | }, 760 | { 761 | filePath: absNormal2, 762 | rawLinkTarget: "../normal2.md?md-link=1", 763 | isInline: false, 764 | inlineLinesRange: undefined, 765 | anchorText: "Normal Link 2", 766 | }, 767 | { 768 | filePath: absInline3, 769 | rawLinkTarget: "./inline3.md?md-embed=50-", 770 | isInline: true, 771 | inlineLinesRange: { from: 49, to: "end" }, 772 | anchorText: "Inline Link 3", 773 | }, 774 | ]; 775 | expect(links).toEqual(expect.arrayContaining(expectedDocLinks)); 776 | expect(links).not.toEqual( 777 | expect.arrayContaining([ 778 | expect.objectContaining({ filePath: expect.stringContaining("ignored.md") }), 779 | expect.objectContaining({ filePath: expect.stringContaining("ignored2.md") }), 780 | ]) 781 | ); 782 | expect(mockFileSystemService.resolvePath).toHaveBeenCalledTimes(5); 783 | }); 784 | }); 785 | --------------------------------------------------------------------------------