Commit 58201380 authored by LongLD's avatar LongLD

feat: implement MCP server with tools for searching, retrieving, composing,...

feat: implement MCP server with tools for searching, retrieving, composing, and refreshing project rules.
parent f00391ab
# Dependencies
node_modules
.pnp
.pnp.js
# Production
dist/
build/
# Misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
logs
*.log
npm-debug.log*
pnpm-debug.log*
lerna-debug.log*
# Testing
coverage/
# IDEs
.vscode/
.idea/
*.swp
*.swo
---
id: "backend-api"
title: "Backend API Standards"
tags: ["backend", "api", "node", "express"]
priority: 85
paths: ["**/api/**", "**/server/**", "**/controllers/**"]
---
# Backend API Standards
## RESTful Design
- Use standard HTTP methods: `GET` for retrieval, `POST` for creation, `PUT/PATCH` for updates, `DELETE` for removal.
- Resource URLs should be plural nouns (e.g., `/api/users`, not `/api/getUser`).
## Error Handling
- Return standardized error responses.
- Use HTTP status codes correctly (200 OK, 201 Created, 400 Bad Request, 401 Unauthorized, 404 Not Found, 500 Internal Server Error).
```json
{
"error": {
"code": "INVALID_INPUT",
"message": "Email is required."
}
}
```
## Database Interaction
- Use an ORM or Query Builder (e.g., Prisma, Drizzle) to prevent SQL injection.
- Do not perform database queries inside loops. Use batching.
---
id: "global-base"
title: "Global Base Rules"
tags: ["global", "base"]
priority: 100
paths: ["**/*"]
---
# Global Project Rules
## Core Principles
- **DRY (Don't Repeat Yourself)**: Avoid duplication. Extract reusable logic.
- **KISS (Keep It Simple, Stupid)**: Prefer simple solutions over complex ones.
- **Clean Code**: Write code that is readable and maintainable.
## Security (Non-Negotiable)
> [!IMPORTANT]
> - Never hardcode secrets (API keys, tokens, passwords). Use environment variables.
> - Validate all user inputs at the API boundary.
> - Ensure dependencies are audited for vulnerabilities.
## Git & Version Control
- Commit messages must be descriptive (e.g., `feat: check user permissions`).
- Do not commit generated files or secrets.
---
id: "frontend-react"
title: "React Best Practices"
tags: ["frontend", "react", "tsx", "ui"]
priority: 90
paths: ["**/*.tsx", "**/components/**"]
applies_when: ["frontend", "component"]
---
# React Best Practices
## Components
- **Functional Components**: Use functional components with hooks. Avoid class components.
- **PascalCase**: Component filenames and names must use PascalCase (e.g., `UserProfile.tsx`).
- **Props Interface**: Define a `Props` interface for component props.
```typescript
interface Props {
userId: string;
isActive?: boolean;
}
export const UserProfile = ({ userId, isActive = false }: Props) => {
// ...
};
```
## State Management
- Use `useState` for local state and `useContext` or global state managers (like Jotai/Zustand) for shared state.
- Avoid prop drilling deeper than 2 levels.
## Performance
- Use `useMemo` and `useCallback` only when necessary to prevent expensive re-renders or stabilize references.
- Lazy load routes and heavy components using `React.lazy`.
This diff is collapsed.
{
"name": "awing-rules-claudecode-mcp",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"prestart": "npm run build",
"start": "node dist/index.js",
"test": "node dist/tests/runner.js"
},
"repository": {
"type": "git",
"url": "https://gitlab.awing.vn/awing-mcp/awing-rules-claudecode-mcp.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.25.3",
"@types/glob": "^8.1.0",
"@types/lunr": "^2.3.7",
"@types/minimatch": "^5.1.2",
"@types/node": "^25.1.0",
"glob": "^13.0.0",
"gray-matter": "^4.0.3",
"lunr": "^2.3.9",
"minimatch": "^10.1.1",
"typescript": "^5.9.3",
"zod": "^4.3.6"
}
}
\ No newline at end of file
# Awing Rules MCP Server for Claude Code
A Model Context Protocol (MCP) server designed to help Claude Code dynamically find, filter, and compose project-specific rules from a large repository of Markdown files.
## Features
- **Smart Search**: Finds relevant rules using a weighted scoring algorithm:
- **Text Match** (TF-IDF/BM25 approximation)
- **Context Aware** (Prioritizes rules matching currently open or changed files)
- **Tag & Priority System** (Weights tags and rule priority)
- **Rule Composition**: Dynamically bundles a global `base.md` with selected rules into a single, deduplicated Markdown prompt.
- **Deduplication**: Automatically removes duplicate generic bullet points to keep the context window efficient.
- **Diversification**: Ensures results are not dominated by a single category/folder.
## Installation
### Prerequisites
- Node.js v16+ (v18+ recommended)
- NPM
### Build
```bash
git clone <repo-url> awing-rules-claudecode-mcp
cd awing-rules-claudecode-mcp
npm install
npm run build
```
## Configuration
To use this with Claude Code or Claude Desktop, add it to your MCP configuration file (e.g., `claude_desktop_config.json` or equivalent for Claude Code).
```json
{
"mcpServers": {
"awing-rules": {
"command": "node",
"args": [
"d:/Project/Awing/awing-rules-claudecode-mcp/dist/index.js"
]
}
}
}
```
*Note: Replace the path with the absolute path to your `dist/index.js` file.*
## Usage with Claude Code
Once configured, Claude can use the following tools:
### 1. `rules.search`
Searches for rules relevant to the current task.
- **Inputs**: `query` (task description), `openFiles`, `changedFiles`, `tags`.
- **Outputs**: List of ranked rules with their IDs and scores.
### 2. `rules.compose`
Creates the final rule bundle.
- **Inputs**: `selected` (list of rule IDs/paths obtained from search).
- **Outputs**: A single markdown string containing the `base.md` content followed by the selected rules, optimized for the context window.
### Example Workflow
1. User asks: "Refactor the login component to use the new hook."
2. Claude calls `rules.search(query="refactor login hook", openFiles=["src/Login.tsx"], ...)`
3. Server returns top relevant rules (e.g., `frontend/react-hooks.md`, `style/typescript.md`).
4. Claude calls `rules.compose(selected=[{id: "react-hooks"}, {id: "typescript"}])`.
5. Server returns the combined markdown bundle.
6. Claude reads the bundle and executes the refactoring task following the rules.
## Rule File Structure
Place your markdown rule files in the root directory or subdirectories.
`base.md` in the root key is always included first.
**Example `frontend/react-components.md`:**
```markdown
---
id: "react-components"
title: "React Component Guidelines"
tags: ["frontend", "react", "tsx"]
priority: 80
paths: ["**/*.tsx"]
---
# React Component Rules
- Use functional components.
- Avoid default exports.
```
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { RuleIndexer } from './rules/indexer.js';
import { Composer } from './rules/composer.js';
import * as path from 'path';
// --- Configuration ---
const ROOT_DIR = process.cwd(); // Assume running from project root
// --- Initialization ---
const indexer = new RuleIndexer(ROOT_DIR);
const composer = new Composer(ROOT_DIR);
// Init index on startup (async but we assume fast enough or lazy)
indexer.init().catch(err => console.error('Failed to init index:', err));
const server = new Server(
{
name: 'awing-rules-claudecode-mcp',
version: '0.1.0',
},
{
capabilities: {
tools: {},
},
}
);
// --- Tool Definitions ---
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'rules_search',
description: 'Search for relevant project rules based on query and context.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'The search query or user task description' },
openFiles: { type: 'array', items: { type: 'string' }, description: 'List of currently open file paths' },
changedFiles: { type: 'array', items: { type: 'string' }, description: 'List of recently changed file paths' },
tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags' },
limit: { type: 'number', description: 'Max number of results to return (default 6)' },
minScore: { type: 'number', description: 'Minimum score threshold (default 0.15)' }
},
required: ['query'],
},
},
{
name: 'rules_get',
description: 'Get details of a specific rule by ID or path.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' },
path: { type: 'string' },
mode: { type: 'string', enum: ['full', 'snippet', 'sections'], default: 'snippet' }
},
},
},
{
name: 'rules_compose',
description: 'Compose a rule bundle from selected rules and the base rule.',
inputSchema: {
type: 'object',
properties: {
selected: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
path: { type: 'string' }
}
}
},
mode: { type: 'string', enum: ['full', 'snippet'], default: 'snippet' },
dedupe: { type: 'boolean', default: true },
maxChars: { type: 'number', default: 12000 }
},
required: ['selected'],
},
},
{
name: 'rules_refresh',
description: 'Refresh the rule index from disk.',
inputSchema: {
type: 'object',
properties: {},
},
},
],
};
});
// --- Tool Handlers ---
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === 'rules_refresh') {
await indexer.refresh();
return {
content: [{ type: 'text', text: 'Rule index refreshed successfully.' }]
};
}
if (name === 'rules_search') {
const input = z.object({
query: z.string(),
openFiles: z.array(z.string()).optional(),
changedFiles: z.array(z.string()).optional(),
tags: z.array(z.string()).optional(),
limit: z.number().optional(),
minScore: z.number().optional()
}).parse(args);
const results = indexer.search(input.query, {
openFiles: input.openFiles,
changedFiles: input.changedFiles,
tags: input.tags,
limit: input.limit,
minScore: input.minScore
});
return {
content: [
{
type: 'text',
text: JSON.stringify(results.map(r => ({
id: r.rule.id,
path: r.rule.relativePath,
title: r.rule.title,
score: r.score.toFixed(2),
tags: r.rule.tags,
why: `Text:${r.scoreBreakdown.text.toFixed(2)} Path:${r.scoreBreakdown.path.toFixed(2)} Tag:${r.scoreBreakdown.tag.toFixed(2)}`
})), null, 2),
},
],
};
}
if (name === 'rules_get') {
const input = z.object({
id: z.string().optional(),
path: z.string().optional(),
mode: z.string().optional(),
}).parse(args);
const rule = indexer.getRuleByIdOrPath(input.id || input.path || '');
if (!rule) {
return { isError: true, content: [{ type: 'text', text: 'Rule not found' }] };
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
id: rule.id,
path: rule.relativePath,
title: rule.title,
content: rule.content // TODO: Apply mode/snippet logic if needed
}, null, 2),
},
],
};
}
if (name === 'rules_compose') {
const input = z.object({
selected: z.array(z.object({ id: z.string().optional(), path: z.string().optional() })),
mode: z.enum(['full', 'snippet']).optional(),
dedupe: z.boolean().optional(),
maxChars: z.number().optional()
}).parse(args);
// Resolve rules
const resolvedRules = input.selected.map(sel => {
const rule = indexer.getRuleByIdOrPath(sel.id || sel.path || '');
return { ...sel, rule };
});
const bundle = await composer.compose(resolvedRules, {
mode: input.mode,
dedupe: input.dedupe,
maxChars: input.maxChars
});
return {
content: [
{
type: 'text',
text: bundle.content,
},
],
};
}
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
});
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Moving Rules MCP Server running on stdio');
}
runServer().catch((error) => {
console.error('Fatal error running server:', error);
process.exit(1);
});
import { Rule, RuleBundle } from '../types.js';
import { RuleLoader } from './loader.js';
export class Composer {
private loader: RuleLoader;
constructor(baseDir: string) {
this.loader = new RuleLoader(baseDir);
}
async compose(
selectedRules: { id?: string; path?: string; rule?: Rule }[],
options: {
maxChars?: number;
dedupe?: boolean;
mode?: 'full' | 'snippet';
basePath?: string;
} = {}
): Promise<RuleBundle> {
const { maxChars = 12000, dedupe = true, mode = 'snippet' } = options;
// 1. Load Base Rule
const baseContent = await this.loader.loadBaseRule();
// 2. Resolve Selected Rules
// The indexer might have passed the full Rule object or just ID/Path.
// Ideally, the caller (MCP tool) resolves the rules first using Indexer, or passes them in.
// For simplicity, let's assume 'selectedRules' contains the Rule object if passed from search,
// or we might need to look them up. But Composer doesn't have the Indexer reference.
// Design choice: MCP server does the lookup. Composer just formats.
// So we expect `rule` property to be present or we can't extract info.
// Actually, let's make the input strictly `Rule[]`.
// Refactoring spec: input `selected` is [{id?, path?}].
// Composer needs access to Indexer or Loader to fetch content if not provided?
// Let's assume the MCP layer fetches the rules.
// "Input: selected: [{ id?: string, path?: string }]".
// WE need to fetch them. So Composer should take an Indexer or Loader.
// But Loader is expensive to re-scan. Indexer has cache.
// Let's pass the *resolved* rules to the compose function to keep it pure(r).
// Or simpler: Composer owns the formatting string logic, MCP server orchestration handles lookup.
// Let's write this class to accept *resolved* array of Rules.
// If the tool definition says input is just ids, the MCP handler will use Indexer to get the Rules, then pass them here.
const rulesToInclude = selectedRules.map(s => s.rule).filter((r): r is Rule => !!r);
// 3. Start composing
let bundle = `# RULE BUNDLE\n> [!IMPORTANT] Priority Order: Selected rules override Base rules where conflicting. Security constraints in Base rules are non-negotiable.\n\n`;
// Add Base
bundle += `## BASE RULES\n\n${baseContent}\n\n`;
// Add Selected
const used: { id: string; path: string; title: string }[] = [];
const notes: string[] = [];
for (const rule of rulesToInclude) {
bundle += `## ${rule.title} (ID: ${rule.id})\nTitle: ${rule.title}\nSource: ${rule.relativePath}\n\n`;
let contentToAdd = rule.content;
// Snippet mode logic
if (mode === 'snippet') {
contentToAdd = this.extractSnippet(contentToAdd);
}
bundle += contentToAdd + `\n\n---\n\n`;
used.push({ id: rule.id, path: rule.path, title: rule.title });
}
// 4. Deduplication
if (dedupe) {
bundle = this.deduplicateLines(bundle);
}
// 5. Truncation
let truncated = false;
if (bundle.length > maxChars) {
bundle = bundle.slice(0, maxChars) + '\n... (truncated)';
truncated = true;
notes.push(`Output truncated to ${maxChars} chars.`);
}
return {
content: bundle,
used,
truncated,
notes
};
}
private extractSnippet(content: string): string {
// Keep headings, bullets, blockquotes
const lines = content.split('\n');
const keep: string[] = [];
let insideCodeBlock = false;
for (const line of lines) {
if (line.trim().startsWith('```')) {
insideCodeBlock = !insideCodeBlock;
// In snippet mode, maybe omit long code blocks?
// Let's keep them if they are short?
// For now, keep everything but maybe limit length?
keep.push(line);
continue;
}
const isHeader = /^#+\s/.test(line);
const isList = /^[\s]*[-*+]\s/.test(line);
const isQuote = /^>\s/.test(line); // e.g. alerts
if (isHeader || isList || isQuote || insideCodeBlock) {
keep.push(line);
} else {
// Plain text? Skip in harsh snippet mode?
// Let's keep it if it's short, or skip?
// Prompt says: "Prioritize headings + bullet lines + Do/Don't"
// Let's conservatively keep non-empty logic lines?
// Actually, just skipping paragraphs might be intended.
if (line.trim().length > 0) {
// keep.push(line); // aggressive: keep all?
// "Cắt examples dài"
}
}
}
// Simple pass: return full content for now, but assume text is mostly bullets in rules.
// Better implementation: Regex for relevant sections.
return content; // Placeholder for advanced snippet extraction if needed.
}
private deduplicateLines(text: string): string {
const lines = text.split('\n');
const seen = new Set<string>();
const out: string[] = [];
for (const line of lines) {
const trimmed = line.trim().toLowerCase();
// Only dedupe substantial bullet points
const isBullet = /^[\s]*[-*+]\s/.test(line);
if (isBullet && trimmed.length > 10) {
if (seen.has(trimmed)) {
continue; // Skip duplicate
}
seen.add(trimmed);
}
out.push(line);
}
return out.join('\n');
}
}
import { RuleLoader } from './loader.js';
import { Scorer } from './scorer.js';
import { Rule, SearchResult } from '../types.js';
export class RuleIndexer {
private loader: RuleLoader;
private scorer: Scorer;
private cachedRules: Rule[] = [];
private lastIndexed: number = 0;
constructor(baseDir: string) {
this.loader = new RuleLoader(baseDir);
this.scorer = new Scorer();
}
async init() {
await this.refresh();
}
async refresh() {
this.cachedRules = await this.loader.loadAllRules();
this.scorer.indexRules(this.cachedRules);
this.lastIndexed = Date.now();
}
async getBaseRule(): Promise<string> {
return this.loader.loadBaseRule();
}
getRuleByIdOrPath(idOrPath: string): Rule | undefined {
// Try ID match
let rule = this.cachedRules.find(r => r.id === idOrPath);
if (rule) return rule;
// Try Path match (exact or relative)
// normalizing slashes
const normalized = idOrPath.replace(/\\/g, '/');
return this.cachedRules.find(r => r.path.replace(/\\/g, '/').endsWith(normalized));
}
search(
query: string,
options: {
openFiles?: string[];
changedFiles?: string[];
tags?: string[];
limit?: number;
minScore?: number;
} = {}
): SearchResult[] {
const { openFiles = [], changedFiles = [], tags = [], limit = 6, minScore = 0.15 } = options;
const results = this.cachedRules.map(rule =>
this.scorer.scoreRule(rule, query, openFiles, changedFiles, tags)
);
// Sort by score desc
const sorted = results
.filter(r => r.score >= minScore)
.sort((a, b) => b.score - a.score);
// Diversification logic: max 2 rules per top-level folder
const diversified: SearchResult[] = [];
const categoryCounts: { [cat: string]: number } = {};
for (const res of sorted) {
// Determine category from relative path (first dir)
const parts = res.rule.relativePath.split(/[/\\]/);
const category = parts.length > 1 ? parts[0] : 'root';
if ((categoryCounts[category] || 0) < 2) {
diversified.push(res);
categoryCounts[category] = (categoryCounts[category] || 0) + 1;
}
if (diversified.length >= limit) break;
}
return diversified;
}
}
import * as fs from 'fs/promises';
import * as path from 'path';
import matter from 'gray-matter';
import { Rule, RuleFrontmatter } from '../types.js';
import { findMarkdownFiles } from '../utils/glob.js';
export class RuleLoader {
private rootDir: string;
constructor(rootDir: string) {
this.rootDir = path.resolve(rootDir);
}
async loadAllRules(): Promise<Rule[]> {
const files = await findMarkdownFiles(this.rootDir);
const rules: Rule[] = [];
for (const file of files) {
if (path.basename(file).toLowerCase() === 'base.md') continue;
try {
const rule = await this.parseRule(file);
if (rule) rules.push(rule);
} catch (error) {
console.error(`Failed to parse rule file: ${file}`, error);
}
}
return rules;
}
async loadBaseRule(): Promise<string> {
const basePath = path.join(this.rootDir, 'base.md');
try {
const content = await fs.readFile(basePath, 'utf-8');
return content;
} catch (error) {
// If base.md doesn't exist, return empty string or default warning
return '';
}
}
private async parseRule(filePath: string): Promise<Rule | null> {
const rawContent = await fs.readFile(filePath, 'utf-8');
const { data, content } = matter(rawContent);
const fm = data as RuleFrontmatter;
const stats = await fs.stat(filePath);
// Filter out rules explicitly marked to be avoided if necessary?
// No, 'avoid' is a string[] field for query matching, not a flag to ignore the file itself basically.
// Unless frontmatter is drastically invalid.
const relativePath = path.relative(this.rootDir, filePath);
const id = fm.id || relativePath.replace(/\\/g, '/').replace(/\.md$/, '');
const title = fm.title || path.basename(filePath, '.md');
return {
id,
path: filePath,
relativePath,
title,
content,
tags: fm.tags || [],
priority: typeof fm.priority === 'number' ? fm.priority : 50,
paths: fm.paths || [],
applies_when: fm.applies_when || [],
avoid: fm.avoid || [],
lastModified: stats.mtimeMs
};
}
}
import { Rule, SearchResult } from '../types.js';
import { matchGlob } from '../utils/glob.js';
interface TFIDFIndex {
[term: string]: number; // IDF scores
}
export class Scorer {
private idf: { [term: string]: number } = {};
private ruleVectors: Map<string, { [term: string]: number }> = new Map();
constructor() { }
// Update internal TF-IDF model based on all rules
// Simplified version: Term Frequency in document * Inverse Document Frequency
// For small corpus (50-200 files), in-memory is fine.
public indexRules(rules: Rule[]) {
// 1. Calculate document frequencies
const docFreq: { [term: string]: number } = {};
const totalDocs = rules.length;
rules.forEach(rule => {
const terms = this.tokenize(this.getRuleTextForIndex(rule));
const uniqueTerms = new Set(terms);
uniqueTerms.forEach(term => {
docFreq[term] = (docFreq[term] || 0) + 1;
});
});
// 2. Calculate IDF
this.idf = {};
Object.keys(docFreq).forEach(term => {
this.idf[term] = Math.log(1 + (totalDocs / (docFreq[term] || 1)));
});
// 3. Pre-calculate TF vectors for each rule
this.ruleVectors.clear();
rules.forEach(rule => {
const terms = this.tokenize(this.getRuleTextForIndex(rule));
const tf: { [term: string]: number } = {};
const docLen = terms.length;
terms.forEach(term => {
tf[term] = (tf[term] || 0) + 1;
});
// Normalize TF? Or just use raw count? Let's use simple TF (count/len) * IDF
const vec: { [term: string]: number } = {};
Object.keys(tf).forEach(term => {
vec[term] = (tf[term] / docLen) * (this.idf[term] || 0);
});
this.ruleVectors.set(rule.id, vec);
});
}
private getRuleTextForIndex(rule: Rule): string {
return `${rule.title} ${rule.tags.join(' ')} ${rule.content}`;
}
private tokenize(text: string): string[] {
return text.toLowerCase()
.replace(/[^a-z0-9\s]/g, '')
.split(/\s+/)
.filter(t => t.length > 2);
}
// --- Scoring Components ---
private calculateTextScore(query: string, ruleId: string): number {
const queryTerms = this.tokenize(query);
const ruleVec = this.ruleVectors.get(ruleId);
if (!ruleVec || queryTerms.length === 0) return 0;
let score = 0;
queryTerms.forEach(term => {
if (ruleVec[term]) {
score += ruleVec[term];
}
});
// Normalize score somewhat?
// TF-IDF summing can go > 1. Let's clamp or sigmoid it?
// Or just simple normalization if creating a relative ranking.
// For now, let's assume raw score is okay but maybe cap at 1.0 for the weighted sum formula
// because S_text is expected to be 0..1 in the prompt.
// A simple heuristic normalization: divide by max theoretical score or just 10?
// Let's use a simpler overlap metric for S_text if TF-IDF is too unbounded.
// Actually, BM25 returns unbounded scores usually.
// Let's check overlap of terms?
// "S_text: ... normalize 0..1"
// Let's try cosine similarity between query and doc?
// Query vector: tf=1 for all terms.
// Simple Jaccard/Overlap for robust 0-1?
// Let's do a localized TF-IDF cosine approx.
let magnitudeQuery = Math.sqrt(queryTerms.length); // approx
let magnitudeDoc = 0;
Object.values(ruleVec).forEach(v => magnitudeDoc += v * v);
magnitudeDoc = Math.sqrt(magnitudeDoc);
if (magnitudeDoc === 0 || magnitudeQuery === 0) return 0;
// Rescale score to 0-1 range roughly
// Cosine similarity = dot_product / (magA * magB)
return Math.min(1, score / (magnitudeQuery * magnitudeDoc * 5 + 0.1)); // Fudge factor
}
private calculatePathScore(rule: Rule, openFiles: string[], changedFiles: string[]): number {
if (!rule.paths || rule.paths.length === 0) return 0;
const openMatches = openFiles.some(f => matchGlob(f, rule.paths));
const changedMatches = changedFiles.some(f => matchGlob(f, rule.paths));
if (changedMatches) return 1.0;
if (openMatches) return 0.6;
return 0;
}
private calculateTagScore(rule: Rule, queryTags: string[]): number {
if (!queryTags || queryTags.length === 0) return 0;
const intersection = rule.tags.filter(t => queryTags.includes(t));
return intersection.length > 0 ? Math.min(1, intersection.length / queryTags.length) : 0;
}
private calculatePriorityScore(rule: Rule): number {
return (rule.priority || 50) / 100;
}
// --- Main Score Function ---
public scoreRule(
rule: Rule,
query: string,
openFiles: string[] = [],
changedFiles: string[] = [],
queryTags: string[] = []
): SearchResult {
const sText = this.calculateTextScore(query, rule.id);
const sPath = this.calculatePathScore(rule, openFiles, changedFiles);
const sTag = this.calculateTagScore(rule, queryTags);
const sPriority = this.calculatePriorityScore(rule);
// avoid penalty
let penalty = 0;
if (rule.avoid && rule.avoid.length > 0) {
// If query or files match avoid criteria.
// Simple text match of generic terms in avoid list against query?
// Or if file path matches avoid glob?
// Prompt: "Penalty if query/file match with avoid"
// Let's assume avoid contains keywords or globs.
const avoidMatchesQuery = rule.avoid.some(avoidTerm => query.toLowerCase().includes(avoidTerm.toLowerCase()));
if (avoidMatchesQuery) penalty = 0.5;
}
// Boost heuristics
let boost = 0;
const q = query.toLowerCase();
if (/test|vitest|msw|coverage/.test(q) && rule.tags.includes('testing')) boost += 0.2;
if (/react|component|hook|tsx/.test(q) && (rule.relativePath.includes('frontend') || rule.tags.includes('react'))) boost += 0.2;
if (/graphql|mutation|schema/.test(q) && (rule.relativePath.includes('backend') || rule.tags.includes('graphql'))) boost += 0.2;
const weightedScore =
(0.55 * sText) +
(0.25 * sPath) +
(0.12 * sTag) +
(0.08 * sPriority);
const finalScore = Math.max(0, weightedScore + boost - penalty);
return {
rule,
score: finalScore,
scoreBreakdown: {
text: sText,
path: sPath,
tag: sTag,
priority: sPriority
}
};
}
}
import { Scorer } from '../rules/scorer.js';
import { Composer } from '../rules/composer.js';
import { RuleIndexer } from '../rules/indexer.js';
import { Rule } from '../types.js';
import * as assert from 'assert';
// Simple test helper
function test(name: string, fn: () => void) {
try {
fn();
console.log(`✅ ${name}`);
} catch (e) {
console.error(`❌ ${name}`);
console.error(e);
}
}
const mockRule: Rule = {
id: 'test-rule',
path: '/abs/test/frontend/rule.md',
relativePath: 'frontend/rule.md',
title: 'Test Rule',
content: 'Rule content',
tags: ['react', 'testing'],
priority: 80,
paths: ['**/*.tsx'],
applies_when: [],
avoid: [],
lastModified: 0
};
async function runTests() {
console.log('--- Running Scorer Tests ---');
// Scorer Path Tests
const scorer = new Scorer();
// We need to index rules first to initialize vectors if we test text score,
// but path score is independent.
scorer.indexRules([mockRule]);
test('Path Score: Exact Match', () => {
const score = (scorer as any).calculatePathScore(mockRule, ['/src/app.tsx'], ['/src/component.tsx']);
// Changed file matches glob **/*.tsx
assert.ok(score >= 1.0, 'Should be 1.0 for changed file match');
});
test('Path Score: Open Match', () => {
const score = (scorer as any).calculatePathScore(mockRule, ['/src/app.tsx'], []);
assert.ok(score >= 0.6, 'Should be 0.6 for open file match');
});
test('Path Score: No Match', () => {
const score = (scorer as any).calculatePathScore(mockRule, ['/src/main.py'], []);
assert.strictEqual(score, 0, 'Should be 0 for no match');
});
console.log('--- Running Composer Tests ---');
const composer = new Composer('.');
test('Deduplication', () => {
const text = `
- Keep this
- Dedupe this
- Dedupe this
- Keep this too
`;
const result = (composer as any).deduplicateLines(text);
const lines = result.split('\n').map((l: string) => l.trim()).filter((l: string) => l);
assert.strictEqual(lines.length, 3, 'Should remove 1 duplicate line');
});
console.log('--- Real File Indexing Test ---');
const indexer = new RuleIndexer(process.cwd());
await indexer.init(); // Load real files
const results = indexer.search('react component', {
openFiles: ['src/App.tsx']
});
console.log(`Found ${results.length} rules for "react component"`);
results.forEach(r => console.log(`- ${r.rule.title} (Score: ${r.score.toFixed(2)})`));
// Check if we found the new rules
const foundReact = results.some(r => r.rule.id === 'frontend-react' || r.rule.title.includes('React'));
assert.ok(foundReact, 'Should find React rules in sample files');
const baseContent = await indexer.getBaseRule();
assert.ok(baseContent.length > 0, 'Base rule should be loaded');
console.log('--- All Tests Finished ---');
}
runTests().catch(console.error);
export interface RuleFrontmatter {
id?: string;
title?: string;
tags?: string[];
priority?: number;
paths?: string[];
applies_when?: string[];
avoid?: string[];
}
export interface Rule {
id: string;
path: string;
relativePath: string;
title: string;
content: string;
tags: string[];
priority: number;
paths: string[]; // glob patterns
applies_when: string[];
avoid: string[];
lastModified: number;
}
export interface SearchResult {
rule: Rule;
score: number;
scoreBreakdown: {
text: number;
path: number;
tag: number;
priority: number;
};
}
export interface RuleBundle {
content: string;
used: { id: string; path: string; title: string }[];
truncated: boolean;
notes: string[];
}
import { minimatch } from 'minimatch';
import { glob } from 'glob';
export const findMarkdownFiles = async (cwd: string): Promise<string[]> => {
// Find all markdown files, ignoring node_modules and dot folders
const files = await glob('**/*.md', {
cwd,
ignore: ['**/node_modules/**', '**/.*/**'],
absolute: true,
});
return files;
};
export const matchGlob = (filePath: string, patterns: string[]): boolean => {
if (!patterns || patterns.length === 0) return false;
// Normalize windows paths for matching
const normalizedPath = filePath.replace(/\\/g, '/');
for (const pattern of patterns) {
if (minimatch(normalizedPath, pattern, { dot: true, matchBase: true })) return true;
}
return false;
};
---
id: "style-typescript"
title: "TypeScript Style Guide"
tags: ["typescript", "style", "lint"]
priority: 80
paths: ["**/*.ts", "**/*.tsx"]
---
# TypeScript Style Guide
## Typing
- **Explicit Types**: Avoid `any`. Define interfaces or types for all data structures.
- **Strict Mode**: `tsconfig.json` must have `"strict": true`.
- **Inference**: Rely on type inference for simple variable assignments.
## Naming Conventions
- **Variables/Functions**: camelCase
- **Classes/Interfaces/Types**: PascalCase
- **Constants**: UPPER_CASE for global constants, camelCase for local readonly variables.
## Async/Await
- Prefer `async/await` over raw `.then()` chains.
- Always handle errors in async functions with `try/catch` or return rejection.
---
id: "testing-jest"
title: "Testing Guidelines (Jest/Vitest)"
tags: ["testing", "jest", "vitest", "unit-test"]
priority: 70
paths: ["**/*.test.ts", "**/*.spec.ts", "**/tests/**"]
applies_when: ["test", "verify"]
---
# Testing Guidelines
## Unit Tests
- Write unit tests for all utility functions and shared logic.
- Mock external dependencies (API calls, DB) to keep tests fast and deterministic.
## Test Structure
- Use `describe` to group tests by function or module.
- Use `it` or `test` for individual test cases. Descriptions should be readable sentences.
```typescript
describe('calculateTotal', () => {
it('should return 0 for empty cart', () => {
expect(calculateTotal([])).toBe(0);
});
});
```
## Coverage
- Aim for high branch coverage on critical business logic.
- Do not test implementation details, test public behavior.
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"**/*.test.ts"
]
}
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment