feat: turbostarter boilerplate
Production-ready Next.js boilerplate with: - Runtime env validation (fail-fast on missing vars) - Feature-gated config (S3, Stripe, email, OAuth) - Docker + Coolify deployment pipeline - PostgreSQL + pgvector, MinIO S3, Better Auth - TypeScript strict mode (no ignoreBuildErrors) - i18n (en/es), AI modules, billing, monitoring Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
packages/cognitive-context/bin/cognitive.js
Executable file
3
packages/cognitive-context/bin/cognitive.js
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
import { run } from '../dist/cli.js';
|
||||
run();
|
||||
46
packages/cognitive-context/package.json
Normal file
46
packages/cognitive-context/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@repo/cognitive-context",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Production-ready context management system for AI coding assistants",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"cognitive": "./bin/cognitive.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.3",
|
||||
"commander": "^11.1.0",
|
||||
"js-tiktoken": "^1.0.8",
|
||||
"yaml": "^2.3.4",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"context",
|
||||
"cursor",
|
||||
"claude",
|
||||
"coding-assistant",
|
||||
"developer-tools"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
186
packages/cognitive-context/src/adapters/claude.ts
Normal file
186
packages/cognitive-context/src/adapters/claude.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Claude Code Adapter
|
||||
*
|
||||
* Standalone interface for Claude Code-specific operations.
|
||||
* Generates CLAUDE.md and syncs commands to .claude/commands/.
|
||||
*/
|
||||
|
||||
import { writeFile, mkdir, rm, readdir } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { loadKnowledge, type KnowledgeData, type SyncOptions } from '../sync.js';
|
||||
import type { SyncResult } from '../types.js';
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
||||
/** Options for generating CLAUDE.md content */
|
||||
export interface ClaudeMdOptions {
|
||||
includeCapabilities?: boolean;
|
||||
includeRules?: boolean;
|
||||
includeWisdom?: boolean;
|
||||
headerContent?: string;
|
||||
footerContent?: string;
|
||||
}
|
||||
|
||||
/** Structure of generated CLAUDE.md */
|
||||
export interface ClaudeMdStructure {
|
||||
projectName: string;
|
||||
mainContent: string;
|
||||
capabilities?: string;
|
||||
rules?: string;
|
||||
wisdomRefs?: Array<{ name: string; heading: string }>;
|
||||
}
|
||||
|
||||
/** Options for syncing commands */
|
||||
export interface SyncCommandsOptions extends SyncOptions {
|
||||
deleteStale?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utilities
|
||||
// ============================================
|
||||
|
||||
function extractFirstHeading(content: string): string {
|
||||
const match = content.match(/^#\s+(.+)$/m);
|
||||
return match ? match[1].trim() : 'Untitled';
|
||||
}
|
||||
|
||||
async function ensureDir(dirPath: string): Promise<void> {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CLAUDE.md Generation
|
||||
// ============================================
|
||||
|
||||
/** Generate CLAUDE.md content from knowledge data */
|
||||
export function generateClaudeMd(
|
||||
knowledge: KnowledgeData,
|
||||
options: ClaudeMdOptions = {}
|
||||
): string {
|
||||
const {
|
||||
includeCapabilities = true,
|
||||
includeRules = true,
|
||||
includeWisdom = true,
|
||||
headerContent,
|
||||
footerContent,
|
||||
} = options;
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
if (headerContent) sections.push(headerContent, '');
|
||||
sections.push(`# ${knowledge.projectName}`, '');
|
||||
if (knowledge.orientation || knowledge.summary) {
|
||||
sections.push(knowledge.orientation || knowledge.summary, '');
|
||||
}
|
||||
|
||||
if (includeCapabilities && Object.keys(knowledge.capabilities).length > 0) {
|
||||
sections.push('## Capabilities', '', '```yaml', stringifyYaml(knowledge.capabilities).trim(), '```', '');
|
||||
}
|
||||
|
||||
if (includeRules && Object.keys(knowledge.rules).length > 0) {
|
||||
sections.push('## Rules', '', '```yaml', stringifyYaml(knowledge.rules).trim(), '```', '');
|
||||
}
|
||||
|
||||
if (includeWisdom && knowledge.wisdom.size > 0) {
|
||||
sections.push('## Wisdom', '', 'Cached patterns and answers:', '');
|
||||
for (const [name, content] of knowledge.wisdom) {
|
||||
sections.push(`- **${name}**: ${extractFirstHeading(content)}`);
|
||||
}
|
||||
sections.push('');
|
||||
}
|
||||
|
||||
if (footerContent) sections.push(footerContent, '');
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Commands Sync
|
||||
// ============================================
|
||||
|
||||
/** Sync commands to .claude/commands/ directory */
|
||||
export async function syncCommands(
|
||||
projectRoot: string,
|
||||
commands: Map<string, string>,
|
||||
options: SyncCommandsOptions = {}
|
||||
): Promise<SyncResult> {
|
||||
const commandsDir = join(projectRoot, '.claude', 'commands');
|
||||
const filesWritten: string[] = [];
|
||||
const filesDeleted: string[] = [];
|
||||
|
||||
try {
|
||||
if (!options.dryRun) await ensureDir(commandsDir);
|
||||
const currentCommands = new Set<string>();
|
||||
|
||||
for (const [name, content] of commands) {
|
||||
const fileName = `${name}.md`;
|
||||
currentCommands.add(fileName);
|
||||
const commandPath = join(commandsDir, fileName);
|
||||
if (!options.dryRun) {
|
||||
await ensureDir(dirname(commandPath));
|
||||
await writeFile(commandPath, content, 'utf-8');
|
||||
}
|
||||
filesWritten.push(commandPath);
|
||||
}
|
||||
|
||||
if (options.deleteStale !== false) {
|
||||
try {
|
||||
const entries = await readdir(commandsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.md') && !currentCommands.has(entry.name)) {
|
||||
const filePath = join(commandsDir, entry.name);
|
||||
if (!options.dryRun) await rm(filePath);
|
||||
filesDeleted.push(filePath);
|
||||
}
|
||||
}
|
||||
} catch { /* Directory doesn't exist */ }
|
||||
}
|
||||
|
||||
return { tool: 'claude', filesWritten, filesDeleted, success: true };
|
||||
} catch (error) {
|
||||
return { tool: 'claude', filesWritten, filesDeleted, success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Full Sync
|
||||
// ============================================
|
||||
|
||||
/** Full sync to Claude Code format (CLAUDE.md + .claude/commands/) */
|
||||
export async function syncToClaudeCode(
|
||||
projectRoot: string,
|
||||
options: SyncOptions & ClaudeMdOptions = {}
|
||||
): Promise<SyncResult> {
|
||||
const filesWritten: string[] = [];
|
||||
const filesDeleted: string[] = [];
|
||||
|
||||
try {
|
||||
const knowledge = await loadKnowledge(projectRoot);
|
||||
|
||||
if (knowledge.summary || knowledge.orientation) {
|
||||
const claudeMdPath = join(projectRoot, 'CLAUDE.md');
|
||||
if (!options.dryRun) await writeFile(claudeMdPath, generateClaudeMd(knowledge, options), 'utf-8');
|
||||
filesWritten.push(claudeMdPath);
|
||||
}
|
||||
|
||||
if (knowledge.commands.size > 0) {
|
||||
const result = await syncCommands(projectRoot, knowledge.commands, options);
|
||||
filesWritten.push(...result.filesWritten);
|
||||
filesDeleted.push(...result.filesDeleted);
|
||||
if (!result.success) return { tool: 'claude', filesWritten, filesDeleted, success: false, error: result.error };
|
||||
}
|
||||
|
||||
return { tool: 'claude', filesWritten, filesDeleted, success: true };
|
||||
} catch (error) {
|
||||
return { tool: 'claude', filesWritten, filesDeleted, success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Re-exports
|
||||
// ============================================
|
||||
|
||||
export { loadKnowledge, type KnowledgeData, type SyncOptions } from '../sync.js';
|
||||
export type { SyncResult } from '../types.js';
|
||||
299
packages/cognitive-context/src/adapters/cursor.ts
Normal file
299
packages/cognitive-context/src/adapters/cursor.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Cursor Adapter Module
|
||||
*
|
||||
* Provides a standalone interface for Cursor-specific operations.
|
||||
* Generates and manages .mdc files in .cursor/rules/.
|
||||
*/
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
loadKnowledge,
|
||||
syncToTool,
|
||||
type SyncOptions,
|
||||
type KnowledgeData,
|
||||
} from '../sync.js';
|
||||
import type { SyncResult, ToolConfig } from '../types.js';
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Options for generating an MDC file
|
||||
*/
|
||||
export interface MdcFileOptions {
|
||||
/** Description for the frontmatter */
|
||||
description: string;
|
||||
/** If true, rule always applies to all files */
|
||||
alwaysApply?: boolean;
|
||||
/** Glob patterns for files this rule applies to (optional) */
|
||||
globs?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of a generated MDC file
|
||||
*/
|
||||
export interface MdcFile {
|
||||
/** File name (without path) */
|
||||
name: string;
|
||||
/** Full MDC content including frontmatter */
|
||||
content: string;
|
||||
/** Parsed frontmatter */
|
||||
frontmatter: MdcFrontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* MDC frontmatter structure
|
||||
*/
|
||||
export interface MdcFrontmatter {
|
||||
description: string;
|
||||
alwaysApply?: boolean;
|
||||
globs?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation result for rules directory
|
||||
*/
|
||||
export interface RulesValidation {
|
||||
/** Whether the rules directory exists */
|
||||
exists: boolean;
|
||||
/** List of .mdc files found */
|
||||
files: string[];
|
||||
/** List of issues found */
|
||||
issues: ValidationIssue[];
|
||||
/** Overall validity */
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual validation issue
|
||||
*/
|
||||
export interface ValidationIssue {
|
||||
file: string;
|
||||
type: 'missing-frontmatter' | 'invalid-frontmatter' | 'empty-content';
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
|
||||
const DEFAULT_RULES_PATH = '.cursor/rules';
|
||||
|
||||
// ============================================
|
||||
// MDC File Generation
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Generate MDC frontmatter string
|
||||
*/
|
||||
function formatMdcFrontmatter(options: MdcFileOptions): string {
|
||||
const lines = ['---', `description: ${options.description}`];
|
||||
|
||||
if (options.alwaysApply) {
|
||||
lines.push('alwaysApply: true');
|
||||
}
|
||||
|
||||
if (options.globs && options.globs.length > 0) {
|
||||
lines.push('globs:');
|
||||
for (const glob of options.globs) {
|
||||
lines.push(` - ${glob}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('---', '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single .mdc file content
|
||||
*
|
||||
* @param name - File name (without .mdc extension)
|
||||
* @param content - Markdown content (without frontmatter)
|
||||
* @param options - MDC file options
|
||||
* @returns Complete MDC file structure
|
||||
*/
|
||||
export function generateMdcFile(
|
||||
name: string,
|
||||
content: string,
|
||||
options: MdcFileOptions
|
||||
): MdcFile {
|
||||
const frontmatter = formatMdcFrontmatter(options);
|
||||
const fullContent = frontmatter + content;
|
||||
|
||||
return {
|
||||
name: name.endsWith('.mdc') ? name : `${name}.mdc`,
|
||||
content: fullContent,
|
||||
frontmatter: {
|
||||
description: options.description,
|
||||
alwaysApply: options.alwaysApply,
|
||||
globs: options.globs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sync Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Sync cognitive knowledge to .cursor/rules
|
||||
*
|
||||
* @param projectRoot - Project root directory
|
||||
* @param options - Sync options
|
||||
* @returns Sync result
|
||||
*/
|
||||
export async function syncToRules(
|
||||
projectRoot: string,
|
||||
options: SyncOptions = {}
|
||||
): Promise<SyncResult> {
|
||||
const knowledge = await loadKnowledge(projectRoot);
|
||||
|
||||
const toolConfig: ToolConfig = {
|
||||
name: 'cursor',
|
||||
enabled: true,
|
||||
outputPath: DEFAULT_RULES_PATH,
|
||||
};
|
||||
|
||||
return syncToTool('cursor', projectRoot, toolConfig, knowledge, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync with pre-loaded knowledge data
|
||||
*
|
||||
* @param projectRoot - Project root directory
|
||||
* @param knowledge - Pre-loaded knowledge data
|
||||
* @param options - Sync options
|
||||
* @returns Sync result
|
||||
*/
|
||||
export async function syncToRulesWithKnowledge(
|
||||
projectRoot: string,
|
||||
knowledge: KnowledgeData,
|
||||
options: SyncOptions = {}
|
||||
): Promise<SyncResult> {
|
||||
const toolConfig: ToolConfig = {
|
||||
name: 'cursor',
|
||||
enabled: true,
|
||||
outputPath: DEFAULT_RULES_PATH,
|
||||
};
|
||||
|
||||
return syncToTool('cursor', projectRoot, toolConfig, knowledge, options);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Validation
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Validate the .cursor/rules directory
|
||||
*
|
||||
* Checks for:
|
||||
* - Directory existence
|
||||
* - Valid .mdc files with proper frontmatter
|
||||
* - Non-empty content
|
||||
*
|
||||
* @param projectRoot - Project root directory
|
||||
* @returns Validation result
|
||||
*/
|
||||
export async function validateRules(
|
||||
projectRoot: string
|
||||
): Promise<RulesValidation> {
|
||||
const rulesDir = join(projectRoot, DEFAULT_RULES_PATH);
|
||||
const issues: ValidationIssue[] = [];
|
||||
const files: string[] = [];
|
||||
|
||||
// Check if directory exists
|
||||
try {
|
||||
const stats = await stat(rulesDir);
|
||||
if (!stats.isDirectory()) {
|
||||
return {
|
||||
exists: false,
|
||||
files: [],
|
||||
issues: [
|
||||
{
|
||||
file: rulesDir,
|
||||
type: 'missing-frontmatter',
|
||||
message: 'Rules path exists but is not a directory',
|
||||
},
|
||||
],
|
||||
valid: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
exists: false,
|
||||
files: [],
|
||||
issues: [],
|
||||
valid: true, // Not existing is valid (just means no rules yet)
|
||||
};
|
||||
}
|
||||
|
||||
// List .mdc files
|
||||
try {
|
||||
const entries = await readdir(rulesDir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.mdc')) {
|
||||
files.push(entry.name);
|
||||
|
||||
// Read and validate file
|
||||
const filePath = join(rulesDir, entry.name);
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
|
||||
// Check for frontmatter
|
||||
if (!content.startsWith('---')) {
|
||||
issues.push({
|
||||
file: entry.name,
|
||||
type: 'missing-frontmatter',
|
||||
message: 'File does not start with YAML frontmatter (---)',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for closing frontmatter
|
||||
const secondDash = content.indexOf('---', 3);
|
||||
if (secondDash === -1) {
|
||||
issues.push({
|
||||
file: entry.name,
|
||||
type: 'invalid-frontmatter',
|
||||
message: 'Frontmatter is not properly closed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for content after frontmatter
|
||||
const bodyContent = content.slice(secondDash + 3).trim();
|
||||
if (!bodyContent) {
|
||||
issues.push({
|
||||
file: entry.name,
|
||||
type: 'empty-content',
|
||||
message: 'File has no content after frontmatter',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
issues.push({
|
||||
file: rulesDir,
|
||||
type: 'invalid-frontmatter',
|
||||
message: `Failed to read rules directory: ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
files,
|
||||
issues,
|
||||
valid: issues.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Re-exports
|
||||
// ============================================
|
||||
|
||||
// Re-export sync types for convenience
|
||||
export type { SyncOptions, KnowledgeData } from '../sync.js';
|
||||
export type { SyncResult, ToolConfig } from '../types.js';
|
||||
8
packages/cognitive-context/src/adapters/index.ts
Normal file
8
packages/cognitive-context/src/adapters/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Tool-specific adapters for Cognitive Context
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './cursor.js';
|
||||
export * from './claude.js';
|
||||
743
packages/cognitive-context/src/cli.ts
Normal file
743
packages/cognitive-context/src/cli.ts
Normal file
@@ -0,0 +1,743 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CLI Interface for Cognitive Context
|
||||
*
|
||||
* Provides commands for managing cognitive context:
|
||||
* - init: Initialize cognitive.config.yaml
|
||||
* - extract: Extract entities from source code
|
||||
* - validate: Validate context completeness
|
||||
* - drift: Check for context drift
|
||||
* - sync: Sync context to enabled tools
|
||||
* - watch: Watch for changes and auto-sync
|
||||
* - status: Show current context status
|
||||
* - hook: Manage git pre-commit hook
|
||||
*/
|
||||
|
||||
import { Command } from 'commander';
|
||||
import { resolve, relative, join } from 'node:path';
|
||||
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
|
||||
|
||||
// Core modules
|
||||
import { initConfig, loadConfig, loadConfigOrDefault, ConfigNotFoundError } from './config.js';
|
||||
import { extractEntitiesFromDir, type ExtractOptions } from './extractor.js';
|
||||
import { validateCapabilitiesFile, formatValidationResult, hasValidationIssues } from './validator.js';
|
||||
import { detectDrift, formatDriftSummary, hasChanges as hasDriftChanges } from './drift.js';
|
||||
import { syncAll, loadKnowledge, getSupportedTools } from './sync.js';
|
||||
import { createWatcher } from './watcher.js';
|
||||
import { countTokens, formatTokenCount } from './tokens.js';
|
||||
import { installHook, uninstallHook, runPreCommitCheck, formatPreCommitOutput } from './hooks/pre-commit.js';
|
||||
import type { KnowledgeGraph, ExtractedEntity } from './types.js';
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
|
||||
const VERSION = '0.1.0';
|
||||
const COGNITIVE_DIR = '.cognitive';
|
||||
const KNOWLEDGE_FILE = 'knowledge.json';
|
||||
const CAPABILITIES_FILE = 'capabilities.yaml';
|
||||
|
||||
// ANSI color codes (inline to avoid external dependency)
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Output Helpers
|
||||
// ============================================
|
||||
|
||||
function c(color: keyof typeof colors, text: string): string {
|
||||
return `${colors[color]}${text}${colors.reset}`;
|
||||
}
|
||||
|
||||
function success(message: string): void {
|
||||
console.log(`${c('green', '✓')} ${message}`);
|
||||
}
|
||||
|
||||
function error(message: string): void {
|
||||
console.error(`${c('red', '✗')} ${message}`);
|
||||
}
|
||||
|
||||
function warn(message: string): void {
|
||||
console.warn(`${c('yellow', '!')} ${message}`);
|
||||
}
|
||||
|
||||
function info(message: string): void {
|
||||
console.log(`${c('blue', 'i')} ${message}`);
|
||||
}
|
||||
|
||||
function heading(title: string): void {
|
||||
console.log(`\n${c('bold', title)}`);
|
||||
console.log(c('dim', '─'.repeat(title.length)));
|
||||
}
|
||||
|
||||
function bullet(text: string, indent: number = 0): void {
|
||||
console.log(`${' '.repeat(indent)}${c('dim', '•')} ${text}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSavedGraph(projectRoot: string): Promise<KnowledgeGraph | null> {
|
||||
try {
|
||||
const graphPath = join(projectRoot, COGNITIVE_DIR, KNOWLEDGE_FILE);
|
||||
const content = await readFile(graphPath, 'utf-8');
|
||||
return JSON.parse(content) as KnowledgeGraph;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGraph(projectRoot: string, graph: KnowledgeGraph): Promise<void> {
|
||||
const cognitiveDir = join(projectRoot, COGNITIVE_DIR);
|
||||
await mkdir(cognitiveDir, { recursive: true });
|
||||
const graphPath = join(cognitiveDir, KNOWLEDGE_FILE);
|
||||
await writeFile(graphPath, JSON.stringify(graph, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function buildKnowledgeGraph(
|
||||
entities: ExtractedEntity[],
|
||||
projectRoot: string,
|
||||
commit: string = 'unknown'
|
||||
): KnowledgeGraph {
|
||||
const entitiesMap: Record<string, ExtractedEntity> = {};
|
||||
|
||||
for (const entity of entities) {
|
||||
// Use path as key for uniqueness
|
||||
entitiesMap[entity.path] = entity;
|
||||
}
|
||||
|
||||
return {
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
fromCommit: commit,
|
||||
totalEntities: entities.length,
|
||||
projectRoot,
|
||||
version: VERSION,
|
||||
},
|
||||
entities: entitiesMap,
|
||||
relationships: [], // Build relationships from imports
|
||||
};
|
||||
}
|
||||
|
||||
async function getGitCommit(projectRoot: string): Promise<string> {
|
||||
try {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const commit = execSync('git rev-parse --short HEAD', {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
return commit;
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Command Implementations
|
||||
// ============================================
|
||||
|
||||
async function cmdInit(_options: { config?: string; verbose?: boolean }): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
try {
|
||||
// Check if config already exists
|
||||
try {
|
||||
await loadConfig(projectRoot);
|
||||
error('Configuration already exists. Remove existing config to reinitialize.');
|
||||
process.exit(1);
|
||||
} catch (e) {
|
||||
if (!(e instanceof ConfigNotFoundError)) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = await initConfig(projectRoot);
|
||||
success(`Created ${c('cyan', relative(projectRoot, configPath))}`);
|
||||
|
||||
// Create .cognitive directory
|
||||
const cognitiveDir = join(projectRoot, COGNITIVE_DIR);
|
||||
await mkdir(cognitiveDir, { recursive: true });
|
||||
success(`Created ${c('cyan', COGNITIVE_DIR + '/')} directory`);
|
||||
|
||||
info('Next steps:');
|
||||
bullet('Run `cognitive extract` to scan your codebase');
|
||||
bullet('Run `cognitive sync` to generate tool-specific files');
|
||||
|
||||
} catch (err) {
|
||||
error(`Failed to initialize: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdExtract(options: { config?: string; verbose?: boolean }): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
try {
|
||||
const config = await loadConfigOrDefault(projectRoot);
|
||||
|
||||
heading('Extracting entities');
|
||||
|
||||
const extractOptions: ExtractOptions = {
|
||||
include: config.includePatterns,
|
||||
exclude: config.excludePatterns,
|
||||
};
|
||||
|
||||
let allEntities: ExtractedEntity[] = [];
|
||||
|
||||
for (const dir of config.sourceDirs) {
|
||||
const sourceDir = resolve(projectRoot, dir);
|
||||
if (!(await fileExists(sourceDir))) {
|
||||
if (options.verbose) {
|
||||
warn(`Skipping non-existent directory: ${dir}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const entities = await extractEntitiesFromDir(sourceDir, extractOptions);
|
||||
if (options.verbose) {
|
||||
info(`${dir}: Found ${entities.length} entities`);
|
||||
}
|
||||
allEntities = allEntities.concat(entities);
|
||||
}
|
||||
|
||||
if (allEntities.length === 0) {
|
||||
warn('No entities found. Check your sourceDirs and patterns in config.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build and save knowledge graph
|
||||
const commit = await getGitCommit(projectRoot);
|
||||
const graph = buildKnowledgeGraph(allEntities, projectRoot, commit);
|
||||
await saveGraph(projectRoot, graph);
|
||||
|
||||
// Summary by type
|
||||
const byType = new Map<string, number>();
|
||||
for (const entity of allEntities) {
|
||||
byType.set(entity.type, (byType.get(entity.type) || 0) + 1);
|
||||
}
|
||||
|
||||
success(`Extracted ${c('bold', String(allEntities.length))} entities`);
|
||||
|
||||
for (const [type, count] of byType) {
|
||||
bullet(`${type}: ${count}`, 1);
|
||||
}
|
||||
|
||||
info(`Knowledge graph saved to ${c('cyan', COGNITIVE_DIR + '/' + KNOWLEDGE_FILE)}`);
|
||||
|
||||
} catch (err) {
|
||||
error(`Extraction failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdValidate(_options: { config?: string; verbose?: boolean }): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
try {
|
||||
const savedGraph = await loadSavedGraph(projectRoot);
|
||||
|
||||
if (!savedGraph) {
|
||||
warn('No knowledge graph found. Run `cognitive extract` first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
heading('Validating context completeness');
|
||||
|
||||
const capabilitiesPath = join(projectRoot, COGNITIVE_DIR, CAPABILITIES_FILE);
|
||||
|
||||
if (!(await fileExists(capabilitiesPath))) {
|
||||
warn(`No ${CAPABILITIES_FILE} found. Create one to validate against.`);
|
||||
info('Tip: Define your project capabilities in .cognitive/capabilities.yaml');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await validateCapabilitiesFile(capabilitiesPath, savedGraph);
|
||||
|
||||
console.log('');
|
||||
console.log(formatValidationResult(result));
|
||||
|
||||
if (hasValidationIssues(result)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
success('Validation passed!');
|
||||
|
||||
} catch (err) {
|
||||
error(`Validation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdDrift(_options: { config?: string; verbose?: boolean }): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
try {
|
||||
const config = await loadConfigOrDefault(projectRoot);
|
||||
const savedGraph = await loadSavedGraph(projectRoot);
|
||||
|
||||
if (!savedGraph) {
|
||||
info('No previous knowledge graph found. Run `cognitive extract` to create one.');
|
||||
return;
|
||||
}
|
||||
|
||||
heading('Checking for drift');
|
||||
|
||||
// Re-extract current state
|
||||
const extractOptions: ExtractOptions = {
|
||||
include: config.includePatterns,
|
||||
exclude: config.excludePatterns,
|
||||
};
|
||||
|
||||
let currentEntities: ExtractedEntity[] = [];
|
||||
|
||||
for (const dir of config.sourceDirs) {
|
||||
const sourceDir = resolve(projectRoot, dir);
|
||||
if (await fileExists(sourceDir)) {
|
||||
const entities = await extractEntitiesFromDir(sourceDir, extractOptions);
|
||||
currentEntities = currentEntities.concat(entities);
|
||||
}
|
||||
}
|
||||
|
||||
const commit = await getGitCommit(projectRoot);
|
||||
const currentGraph = buildKnowledgeGraph(currentEntities, projectRoot, commit);
|
||||
|
||||
const report = detectDrift(currentGraph, savedGraph);
|
||||
|
||||
console.log('');
|
||||
console.log(formatDriftSummary(report));
|
||||
|
||||
if (hasDriftChanges(report)) {
|
||||
warn('Drift detected! Consider running `cognitive extract && cognitive sync`');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
success('No significant drift detected.');
|
||||
|
||||
} catch (err) {
|
||||
error(`Drift check failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdSync(options: { config?: string; verbose?: boolean; dryRun?: boolean }): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
try {
|
||||
const config = await loadConfigOrDefault(projectRoot);
|
||||
|
||||
heading('Syncing to tools');
|
||||
|
||||
const enabledTools = config.tools.filter((t) => t.enabled);
|
||||
|
||||
if (enabledTools.length === 0) {
|
||||
warn('No tools enabled. Enable tools in your cognitive.config.yaml');
|
||||
info(`Supported tools: ${getSupportedTools().join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
info(`Enabled tools: ${enabledTools.map((t) => t.name).join(', ')}`);
|
||||
|
||||
if (options.dryRun) {
|
||||
info(c('yellow', 'Dry run mode - no files will be written'));
|
||||
}
|
||||
|
||||
const toolConfigs = config.tools.map((t) => ({
|
||||
name: t.name,
|
||||
enabled: t.enabled,
|
||||
outputPath: t.outputPath ?? '',
|
||||
}));
|
||||
|
||||
const report = await syncAll(projectRoot, toolConfigs, {
|
||||
dryRun: options.dryRun,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
console.log('');
|
||||
|
||||
for (const result of report.results) {
|
||||
if (result.success) {
|
||||
if (result.filesWritten.length > 0 || result.filesDeleted.length > 0) {
|
||||
success(`${result.tool}: ${result.filesWritten.length} written, ${result.filesDeleted.length} deleted`);
|
||||
if (options.verbose) {
|
||||
for (const file of result.filesWritten) {
|
||||
bullet(c('green', '+') + ' ' + relative(projectRoot, file), 1);
|
||||
}
|
||||
for (const file of result.filesDeleted) {
|
||||
bullet(c('red', '-') + ' ' + relative(projectRoot, file), 1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info(`${result.tool}: No changes needed`);
|
||||
}
|
||||
} else {
|
||||
error(`${result.tool}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
if (report.allSuccessful) {
|
||||
success(`Sync complete: ${report.totalFilesWritten} files written, ${report.totalFilesDeleted} deleted`);
|
||||
} else {
|
||||
warn('Sync completed with errors');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error(`Sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdWatch(options: { config?: string; verbose?: boolean }): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
try {
|
||||
const config = await loadConfigOrDefault(projectRoot);
|
||||
|
||||
heading('Watch mode');
|
||||
|
||||
const sourceDirs = config.sourceDirs.map((d) => resolve(projectRoot, d));
|
||||
|
||||
info(`Watching: ${config.sourceDirs.join(', ')}`);
|
||||
info('Press Ctrl+C to stop');
|
||||
console.log('');
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingChanges = new Set<string>();
|
||||
|
||||
const handleSync = async () => {
|
||||
if (pendingChanges.size === 0) return;
|
||||
|
||||
const changes = Array.from(pendingChanges);
|
||||
pendingChanges.clear();
|
||||
|
||||
info(`Changes detected: ${changes.length} file(s)`);
|
||||
|
||||
// Re-extract and sync
|
||||
const extractOptions: ExtractOptions = {
|
||||
include: config.includePatterns,
|
||||
exclude: config.excludePatterns,
|
||||
};
|
||||
|
||||
let allEntities: ExtractedEntity[] = [];
|
||||
for (const dir of sourceDirs) {
|
||||
if (await fileExists(dir)) {
|
||||
const entities = await extractEntitiesFromDir(dir, extractOptions);
|
||||
allEntities = allEntities.concat(entities);
|
||||
}
|
||||
}
|
||||
|
||||
const commit = await getGitCommit(projectRoot);
|
||||
const graph = buildKnowledgeGraph(allEntities, projectRoot, commit);
|
||||
await saveGraph(projectRoot, graph);
|
||||
|
||||
const toolConfigs = config.tools.map((t) => ({
|
||||
name: t.name,
|
||||
enabled: t.enabled,
|
||||
outputPath: t.outputPath ?? '',
|
||||
}));
|
||||
|
||||
const report = await syncAll(projectRoot, toolConfigs, { verbose: options.verbose });
|
||||
|
||||
if (report.allSuccessful && report.totalFilesWritten > 0) {
|
||||
success(`Synced ${report.totalFilesWritten} files`);
|
||||
}
|
||||
};
|
||||
|
||||
const watcher = createWatcher(
|
||||
sourceDirs,
|
||||
{
|
||||
enabled: true,
|
||||
debounceMs: config.watch.debounceMs,
|
||||
ignorePaths: config.watch.ignorePaths,
|
||||
},
|
||||
{
|
||||
onFileChange: (event) => {
|
||||
pendingChanges.add(event.path);
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(() => {
|
||||
handleSync().catch((err) => {
|
||||
error(`Sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
});
|
||||
}, config.watch.debounceMs);
|
||||
},
|
||||
onError: (err) => {
|
||||
error(`Watch error: ${err.message}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
watcher.start();
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('');
|
||||
info('Stopping watch mode...');
|
||||
watcher.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Keep the process running
|
||||
await new Promise(() => {});
|
||||
|
||||
} catch (err) {
|
||||
error(`Watch failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdStatus(_options: { config?: string; verbose?: boolean }): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
try {
|
||||
heading('Cognitive Context Status');
|
||||
|
||||
// Check for config
|
||||
let hasConfig = false;
|
||||
try {
|
||||
await loadConfig(projectRoot);
|
||||
hasConfig = true;
|
||||
success('Configuration: Found');
|
||||
} catch (e) {
|
||||
if (e instanceof ConfigNotFoundError) {
|
||||
warn('Configuration: Not found (run `cognitive init`)');
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for knowledge graph
|
||||
const savedGraph = await loadSavedGraph(projectRoot);
|
||||
if (savedGraph) {
|
||||
success(`Knowledge graph: ${savedGraph.meta.totalEntities} entities`);
|
||||
bullet(`Generated: ${new Date(savedGraph.meta.generatedAt).toLocaleString()}`, 1);
|
||||
bullet(`Commit: ${savedGraph.meta.fromCommit}`, 1);
|
||||
} else {
|
||||
warn('Knowledge graph: Not found (run `cognitive extract`)');
|
||||
}
|
||||
|
||||
// Check for .cognitive directory
|
||||
const cognitiveDir = join(projectRoot, COGNITIVE_DIR);
|
||||
if (await fileExists(cognitiveDir)) {
|
||||
success(`.cognitive/ directory: Found`);
|
||||
} else {
|
||||
warn(`.cognitive/ directory: Not found`);
|
||||
}
|
||||
|
||||
// Check for knowledge files
|
||||
const knowledge = await loadKnowledge(projectRoot);
|
||||
|
||||
console.log('');
|
||||
info('Knowledge files:');
|
||||
|
||||
if (knowledge.summary) {
|
||||
const tokens = countTokens(knowledge.summary);
|
||||
bullet(`SUMMARY.md: ${formatTokenCount(tokens)} tokens`, 1);
|
||||
} else {
|
||||
bullet(c('dim', 'SUMMARY.md: Not found'), 1);
|
||||
}
|
||||
|
||||
if (Object.keys(knowledge.capabilities).length > 0) {
|
||||
bullet(`capabilities.yaml: ${Object.keys(knowledge.capabilities).length} top-level keys`, 1);
|
||||
} else {
|
||||
bullet(c('dim', 'capabilities.yaml: Not found'), 1);
|
||||
}
|
||||
|
||||
if (knowledge.wisdom.size > 0) {
|
||||
bullet(`Wisdom files: ${knowledge.wisdom.size}`, 1);
|
||||
}
|
||||
|
||||
if (knowledge.commands.size > 0) {
|
||||
bullet(`Commands: ${knowledge.commands.size}`, 1);
|
||||
}
|
||||
|
||||
// Show enabled tools
|
||||
if (hasConfig) {
|
||||
const config = await loadConfigOrDefault(projectRoot);
|
||||
const enabledTools = config.tools.filter((t) => t.enabled).map((t) => t.name);
|
||||
|
||||
console.log('');
|
||||
info(`Enabled tools: ${enabledTools.length > 0 ? enabledTools.join(', ') : 'None'}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
|
||||
} catch (err) {
|
||||
error(`Status check failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdHook(action: string, _options: { config?: string; verbose?: boolean }): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'install':
|
||||
await installHook(projectRoot);
|
||||
success('Pre-commit hook installed');
|
||||
info('Hook will run `cognitive pre-commit` before each commit');
|
||||
break;
|
||||
|
||||
case 'uninstall':
|
||||
await uninstallHook(projectRoot);
|
||||
success('Pre-commit hook uninstalled');
|
||||
break;
|
||||
|
||||
default:
|
||||
error(`Unknown action: ${action}. Use 'install' or 'uninstall'`);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
error(`Hook ${action} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdPreCommit(options: { config?: string; verbose?: boolean; block?: boolean }): Promise<void> {
|
||||
const projectRoot = process.cwd();
|
||||
|
||||
try {
|
||||
const result = await runPreCommitCheck(projectRoot, {
|
||||
blockOnStale: options.block,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
console.log(formatPreCommitOutput(result));
|
||||
|
||||
if (!result.passed) {
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
error(`Pre-commit check failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Program Factory
|
||||
// ============================================
|
||||
|
||||
export function createProgram(): Command {
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('cognitive')
|
||||
.description('Cognitive Context System - AI coding assistant context management')
|
||||
.version(VERSION)
|
||||
.option('-c, --config <path>', 'Path to config file')
|
||||
.option('-v, --verbose', 'Enable verbose output');
|
||||
|
||||
program
|
||||
.command('init')
|
||||
.description('Initialize cognitive.config.yaml in project')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await cmdInit(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('extract')
|
||||
.description('Extract entities from source code')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await cmdExtract(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('validate')
|
||||
.description('Validate context completeness')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await cmdValidate(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('drift')
|
||||
.description('Check for context drift')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await cmdDrift(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('sync')
|
||||
.description('Sync context to enabled tools')
|
||||
.option('-n, --dry-run', 'Show what would be done without making changes')
|
||||
.action(async (cmdOpts) => {
|
||||
const opts = { ...program.opts(), ...cmdOpts };
|
||||
await cmdSync(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('watch')
|
||||
.description('Watch for changes and auto-sync')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await cmdWatch(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('status')
|
||||
.description('Show current context status')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await cmdStatus(opts);
|
||||
});
|
||||
|
||||
program
|
||||
.command('hook <action>')
|
||||
.description('Manage git pre-commit hook (install/uninstall)')
|
||||
.action(async (action) => {
|
||||
const opts = program.opts();
|
||||
await cmdHook(action, opts);
|
||||
});
|
||||
|
||||
// Hidden command for pre-commit hook
|
||||
program
|
||||
.command('pre-commit', { hidden: true })
|
||||
.description('Run pre-commit validation (used by git hook)')
|
||||
.option('-b, --block', 'Block commit if context is stale')
|
||||
.action(async (cmdOpts) => {
|
||||
const opts = { ...program.opts(), ...cmdOpts };
|
||||
await cmdPreCommit(opts);
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Entry Point
|
||||
// ============================================
|
||||
|
||||
export async function run(): Promise<void> {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(process.argv);
|
||||
}
|
||||
|
||||
// Note: Direct execution is handled by bin/cognitive.js
|
||||
// This module exports run() and createProgram() for programmatic use
|
||||
202
packages/cognitive-context/src/config.schema.ts
Normal file
202
packages/cognitive-context/src/config.schema.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Configuration schema and validation using Zod
|
||||
*
|
||||
* Defines the structure of cognitive.config.yaml
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ============================================
|
||||
// Tool Configuration Schema
|
||||
// ============================================
|
||||
|
||||
export const toolNameSchema = z.enum([
|
||||
'cursor',
|
||||
'claude',
|
||||
'continue',
|
||||
'aider',
|
||||
'copilot',
|
||||
'windsurf',
|
||||
]);
|
||||
|
||||
export const toolConfigSchema = z.object({
|
||||
name: toolNameSchema,
|
||||
enabled: z.boolean().default(true),
|
||||
outputPath: z.string().optional(),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Token Budget Schema
|
||||
// ============================================
|
||||
|
||||
export const tokenBudgetSchema = z.object({
|
||||
/** Maximum tokens for SUMMARY.md (default: 300) */
|
||||
summary: z.number().min(100).max(1000).default(300),
|
||||
/** Maximum tokens for capabilities.yaml (default: 2000) */
|
||||
capabilities: z.number().min(500).max(10000).default(2000),
|
||||
/** Maximum tokens per wisdom file (default: 1500) */
|
||||
wisdomPerFile: z.number().min(200).max(5000).default(1500),
|
||||
/** Total budget across all context files (default: 20000) */
|
||||
total: z.number().min(5000).max(100000).default(20000),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Watch Configuration Schema
|
||||
// ============================================
|
||||
|
||||
export const watchConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
debounceMs: z.number().min(100).max(5000).default(500),
|
||||
ignorePaths: z.array(z.string()).default([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'coverage',
|
||||
]),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Main Configuration Schema
|
||||
// ============================================
|
||||
|
||||
export const cognitiveConfigSchema = z.object({
|
||||
/** Schema version for migrations */
|
||||
version: z.literal('1.0').default('1.0'),
|
||||
|
||||
/** Project root (auto-detected if not specified) */
|
||||
projectRoot: z.string().optional(),
|
||||
|
||||
/** Source directories to scan for entities */
|
||||
sourceDirs: z.array(z.string()).default([
|
||||
'src',
|
||||
'app',
|
||||
'lib',
|
||||
'packages/*/src',
|
||||
]),
|
||||
|
||||
/** Glob patterns for files to include */
|
||||
includePatterns: z.array(z.string()).default([
|
||||
'**/*.ts',
|
||||
'**/*.tsx',
|
||||
'**/*.js',
|
||||
'**/*.jsx',
|
||||
]),
|
||||
|
||||
/** Glob patterns for files to exclude */
|
||||
excludePatterns: z.array(z.string()).default([
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
'**/*.stories.*',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
]),
|
||||
|
||||
/** Token budgets for various context files */
|
||||
tokenBudget: tokenBudgetSchema.default({}),
|
||||
|
||||
/** Tools to sync context to */
|
||||
tools: z.array(toolConfigSchema).default([
|
||||
{ name: 'cursor', enabled: true },
|
||||
{ name: 'claude', enabled: true },
|
||||
]),
|
||||
|
||||
/** Watch mode configuration */
|
||||
watch: watchConfigSchema.default({}),
|
||||
|
||||
/** Output directory for generated context (default: .cognitive) */
|
||||
outputDir: z.string().default('.cognitive'),
|
||||
|
||||
/** Whether to auto-commit context changes */
|
||||
autoCommit: z.boolean().default(false),
|
||||
|
||||
/** CI mode - stricter validation, non-zero exit on issues */
|
||||
ci: z.boolean().default(false),
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Type Exports
|
||||
// ============================================
|
||||
|
||||
export type ToolName = z.infer<typeof toolNameSchema>;
|
||||
export type ToolConfigInput = z.input<typeof toolConfigSchema>;
|
||||
export type TokenBudgetInput = z.input<typeof tokenBudgetSchema>;
|
||||
export type WatchConfigInput = z.input<typeof watchConfigSchema>;
|
||||
export type CognitiveConfigInput = z.input<typeof cognitiveConfigSchema>;
|
||||
export type CognitiveConfigOutput = z.output<typeof cognitiveConfigSchema>;
|
||||
|
||||
// ============================================
|
||||
// Default Configuration
|
||||
// ============================================
|
||||
|
||||
export const DEFAULT_CONFIG: CognitiveConfigOutput = cognitiveConfigSchema.parse({});
|
||||
|
||||
// ============================================
|
||||
// Config File Templates
|
||||
// ============================================
|
||||
|
||||
export const CONFIG_FILE_TEMPLATE = `# Cognitive Context Configuration
|
||||
# https://github.com/your-org/cognitive-context
|
||||
|
||||
version: "1.0"
|
||||
|
||||
# Directories to scan for entities
|
||||
sourceDirs:
|
||||
- src
|
||||
- app
|
||||
- lib
|
||||
|
||||
# File patterns to include
|
||||
includePatterns:
|
||||
- "**/*.ts"
|
||||
- "**/*.tsx"
|
||||
|
||||
# Patterns to exclude
|
||||
excludePatterns:
|
||||
- "**/*.test.*"
|
||||
- "**/*.spec.*"
|
||||
- "**/node_modules/**"
|
||||
|
||||
# Token budgets (prevents context overflow)
|
||||
tokenBudget:
|
||||
summary: 300 # SUMMARY.md max tokens
|
||||
capabilities: 2000 # capabilities.yaml max tokens
|
||||
wisdomPerFile: 1500 # Per wisdom file
|
||||
total: 20000 # Total context budget
|
||||
|
||||
# Tools to sync context to
|
||||
tools:
|
||||
- name: cursor
|
||||
enabled: true
|
||||
- name: claude
|
||||
enabled: true
|
||||
|
||||
# Watch mode (auto-update on file changes)
|
||||
watch:
|
||||
enabled: false
|
||||
debounceMs: 500
|
||||
`;
|
||||
|
||||
// ============================================
|
||||
// Validation Helpers
|
||||
// ============================================
|
||||
|
||||
export function validateConfig(input: unknown): CognitiveConfigOutput {
|
||||
return cognitiveConfigSchema.parse(input);
|
||||
}
|
||||
|
||||
export function validateConfigSafe(input: unknown): {
|
||||
success: boolean;
|
||||
data?: CognitiveConfigOutput;
|
||||
error?: z.ZodError;
|
||||
} {
|
||||
const result = cognitiveConfigSchema.safeParse(input);
|
||||
if (result.success) {
|
||||
return { success: true, data: result.data };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
286
packages/cognitive-context/src/config.test.ts
Normal file
286
packages/cognitive-context/src/config.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Tests for configuration loader
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
findConfigFile,
|
||||
loadConfig,
|
||||
initConfig,
|
||||
loadConfigOrDefault,
|
||||
ConfigNotFoundError,
|
||||
ConfigParseError,
|
||||
ConfigValidationError,
|
||||
} from './config.js';
|
||||
import { DEFAULT_CONFIG } from './config.schema.js';
|
||||
|
||||
// ============================================
|
||||
// Test Fixtures
|
||||
// ============================================
|
||||
|
||||
const VALID_CONFIG = `
|
||||
version: "1.0"
|
||||
sourceDirs:
|
||||
- src
|
||||
- lib
|
||||
includePatterns:
|
||||
- "**/*.ts"
|
||||
tokenBudget:
|
||||
summary: 400
|
||||
total: 25000
|
||||
tools:
|
||||
- name: cursor
|
||||
enabled: true
|
||||
`;
|
||||
|
||||
const MINIMAL_CONFIG = `
|
||||
version: "1.0"
|
||||
`;
|
||||
|
||||
const INVALID_YAML = `
|
||||
version: "1.0"
|
||||
sourceDirs:
|
||||
- src
|
||||
invalid yaml here
|
||||
- missing colon
|
||||
`;
|
||||
|
||||
const INVALID_CONFIG = `
|
||||
version: "2.0"
|
||||
tokenBudget:
|
||||
summary: 50
|
||||
`;
|
||||
|
||||
// ============================================
|
||||
// Test Helpers
|
||||
// ============================================
|
||||
|
||||
async function createTempDir(): Promise<string> {
|
||||
const tempDir = join(tmpdir(), `cognitive-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
async function createNestedDirs(base: string, ...paths: string[]): Promise<string> {
|
||||
const fullPath = join(base, ...paths);
|
||||
await mkdir(fullPath, { recursive: true });
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests
|
||||
// ============================================
|
||||
|
||||
describe('findConfigFile', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should find cognitive.config.yaml in current directory', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, VALID_CONFIG);
|
||||
|
||||
const result = await findConfigFile(tempDir);
|
||||
expect(result).toBe(configPath);
|
||||
});
|
||||
|
||||
it('should find cognitive.config.yml as fallback', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yml');
|
||||
await writeFile(configPath, VALID_CONFIG);
|
||||
|
||||
const result = await findConfigFile(tempDir);
|
||||
expect(result).toBe(configPath);
|
||||
});
|
||||
|
||||
it('should find .cognitive/config.yaml as fallback', async () => {
|
||||
const cognitiveDir = join(tempDir, '.cognitive');
|
||||
await mkdir(cognitiveDir);
|
||||
const configPath = join(cognitiveDir, 'config.yaml');
|
||||
await writeFile(configPath, VALID_CONFIG);
|
||||
|
||||
const result = await findConfigFile(tempDir);
|
||||
expect(result).toBe(configPath);
|
||||
});
|
||||
|
||||
it('should prefer cognitive.config.yaml over alternatives', async () => {
|
||||
const yamlPath = join(tempDir, 'cognitive.config.yaml');
|
||||
const ymlPath = join(tempDir, 'cognitive.config.yml');
|
||||
await writeFile(yamlPath, VALID_CONFIG);
|
||||
await writeFile(ymlPath, VALID_CONFIG);
|
||||
|
||||
const result = await findConfigFile(tempDir);
|
||||
expect(result).toBe(yamlPath);
|
||||
});
|
||||
|
||||
it('should walk up directory tree to find config', async () => {
|
||||
const nestedDir = await createNestedDirs(tempDir, 'a', 'b', 'c');
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, VALID_CONFIG);
|
||||
|
||||
const result = await findConfigFile(nestedDir);
|
||||
expect(result).toBe(configPath);
|
||||
});
|
||||
|
||||
it('should return null when no config exists', async () => {
|
||||
const result = await findConfigFile(tempDir);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load and validate a valid config', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, VALID_CONFIG);
|
||||
|
||||
const config = await loadConfig(tempDir);
|
||||
|
||||
expect(config.version).toBe('1.0');
|
||||
expect(config.sourceDirs).toEqual(['src', 'lib']);
|
||||
expect(config.includePatterns).toEqual(['**/*.ts']);
|
||||
expect(config.tokenBudget.summary).toBe(400);
|
||||
expect(config.tokenBudget.total).toBe(25000);
|
||||
expect(config.projectRoot).toBe(tempDir);
|
||||
});
|
||||
|
||||
it('should apply defaults for missing fields', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, MINIMAL_CONFIG);
|
||||
|
||||
const config = await loadConfig(tempDir);
|
||||
|
||||
expect(config.version).toBe('1.0');
|
||||
expect(config.sourceDirs).toEqual(DEFAULT_CONFIG.sourceDirs);
|
||||
expect(config.includePatterns).toEqual(DEFAULT_CONFIG.includePatterns);
|
||||
expect(config.excludePatterns).toEqual(DEFAULT_CONFIG.excludePatterns);
|
||||
expect(config.tokenBudget).toEqual(DEFAULT_CONFIG.tokenBudget);
|
||||
expect(config.outputDir).toBe('.cognitive');
|
||||
});
|
||||
|
||||
it('should throw ConfigNotFoundError when no config exists', async () => {
|
||||
await expect(loadConfig(tempDir)).rejects.toThrow(ConfigNotFoundError);
|
||||
});
|
||||
|
||||
it('should throw ConfigParseError for invalid YAML', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, INVALID_YAML);
|
||||
|
||||
await expect(loadConfig(tempDir)).rejects.toThrow(ConfigParseError);
|
||||
});
|
||||
|
||||
it('should throw ConfigValidationError for invalid config values', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, INVALID_CONFIG);
|
||||
|
||||
await expect(loadConfig(tempDir)).rejects.toThrow(ConfigValidationError);
|
||||
});
|
||||
|
||||
it('should handle empty config file', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, '');
|
||||
|
||||
const config = await loadConfig(tempDir);
|
||||
// Should apply all defaults
|
||||
expect(config.version).toBe('1.0');
|
||||
expect(config.sourceDirs).toEqual(DEFAULT_CONFIG.sourceDirs);
|
||||
});
|
||||
|
||||
it('should resolve relative projectRoot to absolute path', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, `
|
||||
version: "1.0"
|
||||
projectRoot: "./subdir"
|
||||
`);
|
||||
await mkdir(join(tempDir, 'subdir'));
|
||||
|
||||
const config = await loadConfig(tempDir);
|
||||
expect(config.projectRoot).toBe(join(tempDir, 'subdir'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('initConfig', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should create a default config file', async () => {
|
||||
const configPath = await initConfig(tempDir);
|
||||
|
||||
expect(configPath).toBe(join(tempDir, 'cognitive.config.yaml'));
|
||||
|
||||
// Should be loadable
|
||||
const config = await loadConfig(tempDir);
|
||||
expect(config.version).toBe('1.0');
|
||||
});
|
||||
|
||||
it('should throw if config already exists', async () => {
|
||||
await writeFile(join(tempDir, 'cognitive.config.yaml'), VALID_CONFIG);
|
||||
|
||||
await expect(initConfig(tempDir)).rejects.toThrow('already exists');
|
||||
});
|
||||
|
||||
it('should throw if directory does not exist', async () => {
|
||||
const nonExistent = join(tempDir, 'nonexistent');
|
||||
|
||||
await expect(initConfig(nonExistent)).rejects.toThrow('does not exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfigOrDefault', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should load config when it exists', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, VALID_CONFIG);
|
||||
|
||||
const config = await loadConfigOrDefault(tempDir);
|
||||
expect(config.tokenBudget.summary).toBe(400);
|
||||
});
|
||||
|
||||
it('should return defaults when no config exists', async () => {
|
||||
const config = await loadConfigOrDefault(tempDir);
|
||||
|
||||
expect(config.version).toBe(DEFAULT_CONFIG.version);
|
||||
expect(config.sourceDirs).toEqual(DEFAULT_CONFIG.sourceDirs);
|
||||
expect(config.projectRoot).toBe(tempDir);
|
||||
});
|
||||
|
||||
it('should still throw for parse errors', async () => {
|
||||
const configPath = join(tempDir, 'cognitive.config.yaml');
|
||||
await writeFile(configPath, INVALID_YAML);
|
||||
|
||||
await expect(loadConfigOrDefault(tempDir)).rejects.toThrow(ConfigParseError);
|
||||
});
|
||||
});
|
||||
244
packages/cognitive-context/src/config.ts
Normal file
244
packages/cognitive-context/src/config.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Configuration loader for cognitive-context
|
||||
*
|
||||
* Handles finding, loading, and validating cognitive.config.yaml files.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, access, stat } from 'node:fs/promises';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import {
|
||||
validateConfig,
|
||||
DEFAULT_CONFIG,
|
||||
CONFIG_FILE_TEMPLATE,
|
||||
type CognitiveConfigOutput,
|
||||
} from './config.schema.js';
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
|
||||
/** Config file names to search for, in priority order */
|
||||
const CONFIG_FILE_NAMES = [
|
||||
'cognitive.config.yaml',
|
||||
'cognitive.config.yml',
|
||||
'.cognitive/config.yaml',
|
||||
] as const;
|
||||
|
||||
// ============================================
|
||||
// Error Classes
|
||||
// ============================================
|
||||
|
||||
export class ConfigNotFoundError extends Error {
|
||||
constructor(searchPath: string) {
|
||||
super(
|
||||
`No cognitive config file found. Searched from: ${searchPath}\n` +
|
||||
`Create one with: cognitive init\n` +
|
||||
`Or create manually: cognitive.config.yaml`
|
||||
);
|
||||
this.name = 'ConfigNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigParseError extends Error {
|
||||
constructor(filePath: string, cause: unknown) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
super(`Failed to parse config file: ${filePath}\n${message}`, { cause });
|
||||
this.name = 'ConfigParseError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigValidationError extends Error {
|
||||
constructor(filePath: string, cause: unknown) {
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
super(`Invalid config in: ${filePath}\n${message}`, { cause });
|
||||
this.name = 'ConfigValidationError';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if a file exists
|
||||
*/
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a directory
|
||||
*/
|
||||
async function isDirectory(path: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await stat(path);
|
||||
return stats.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the filesystem root for the current platform
|
||||
*/
|
||||
function getFilesystemRoot(): string {
|
||||
return process.platform === 'win32' ? dirname(process.cwd()).split('\\')[0] + '\\' : '/';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Find a cognitive config file by walking up the directory tree.
|
||||
*
|
||||
* Search order in each directory:
|
||||
* 1. cognitive.config.yaml
|
||||
* 2. cognitive.config.yml
|
||||
* 3. .cognitive/config.yaml
|
||||
*
|
||||
* @param startDir - Directory to start searching from
|
||||
* @returns Absolute path to config file, or null if not found
|
||||
*/
|
||||
export async function findConfigFile(startDir: string): Promise<string | null> {
|
||||
let currentDir = resolve(startDir);
|
||||
const root = getFilesystemRoot();
|
||||
|
||||
while (true) {
|
||||
// Check each possible config file name
|
||||
for (const fileName of CONFIG_FILE_NAMES) {
|
||||
const configPath = join(currentDir, fileName);
|
||||
if (await fileExists(configPath)) {
|
||||
return configPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've reached the filesystem root
|
||||
const parentDir = dirname(currentDir);
|
||||
if (parentDir === currentDir || currentDir === root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate a cognitive config file.
|
||||
*
|
||||
* @param projectRoot - Optional project root directory. If not provided,
|
||||
* searches up from current directory.
|
||||
* @returns Validated and merged configuration
|
||||
* @throws ConfigNotFoundError if no config file is found
|
||||
* @throws ConfigParseError if YAML parsing fails
|
||||
* @throws ConfigValidationError if config validation fails
|
||||
*/
|
||||
export async function loadConfig(projectRoot?: string): Promise<CognitiveConfigOutput> {
|
||||
const searchDir = projectRoot ?? process.cwd();
|
||||
const resolvedSearchDir = resolve(searchDir);
|
||||
|
||||
// Find the config file
|
||||
const configPath = await findConfigFile(resolvedSearchDir);
|
||||
if (!configPath) {
|
||||
throw new ConfigNotFoundError(resolvedSearchDir);
|
||||
}
|
||||
|
||||
// Read and parse the config file
|
||||
let rawContent: string;
|
||||
try {
|
||||
rawContent = await readFile(configPath, 'utf-8');
|
||||
} catch (error) {
|
||||
throw new ConfigParseError(configPath, error);
|
||||
}
|
||||
|
||||
// Parse YAML
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(rawContent);
|
||||
} catch (error) {
|
||||
throw new ConfigParseError(configPath, error);
|
||||
}
|
||||
|
||||
// Handle empty config file (parsed as null) and ensure object type
|
||||
const parsedConfig: Record<string, unknown> =
|
||||
parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
// Merge with defaults and validate
|
||||
const configDir = dirname(configPath);
|
||||
const mergedInput = {
|
||||
...parsedConfig,
|
||||
// Set projectRoot to the directory containing the config file if not specified
|
||||
projectRoot:
|
||||
parsedConfig.projectRoot !== undefined
|
||||
? resolve(configDir, String(parsedConfig.projectRoot))
|
||||
: configDir,
|
||||
};
|
||||
|
||||
try {
|
||||
const validated = validateConfig(mergedInput);
|
||||
return validated;
|
||||
} catch (error) {
|
||||
throw new ConfigValidationError(configPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a new cognitive config file in the specified directory.
|
||||
*
|
||||
* @param projectRoot - Directory where to create the config file
|
||||
* @returns Absolute path to the created config file
|
||||
* @throws Error if the file already exists or cannot be written
|
||||
*/
|
||||
export async function initConfig(projectRoot: string): Promise<string> {
|
||||
const resolvedRoot = resolve(projectRoot);
|
||||
|
||||
// Verify the directory exists
|
||||
if (!(await isDirectory(resolvedRoot))) {
|
||||
throw new Error(`Directory does not exist: ${resolvedRoot}`);
|
||||
}
|
||||
|
||||
// Check if config already exists
|
||||
for (const fileName of CONFIG_FILE_NAMES) {
|
||||
const configPath = join(resolvedRoot, fileName);
|
||||
if (await fileExists(configPath)) {
|
||||
throw new Error(`Config file already exists: ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create the default config file
|
||||
const configPath = join(resolvedRoot, CONFIG_FILE_NAMES[0]);
|
||||
await writeFile(configPath, CONFIG_FILE_TEMPLATE, 'utf-8');
|
||||
|
||||
return configPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config with defaults if no config file exists.
|
||||
* Unlike loadConfig, this does not throw if no config is found.
|
||||
*
|
||||
* @param projectRoot - Optional project root directory
|
||||
* @returns Configuration (loaded or default)
|
||||
*/
|
||||
export async function loadConfigOrDefault(projectRoot?: string): Promise<CognitiveConfigOutput> {
|
||||
try {
|
||||
return await loadConfig(projectRoot);
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigNotFoundError) {
|
||||
// Return defaults with projectRoot set
|
||||
const resolvedRoot = resolve(projectRoot ?? process.cwd());
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
projectRoot: resolvedRoot,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
558
packages/cognitive-context/src/drift.ts
Normal file
558
packages/cognitive-context/src/drift.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* Drift Detector
|
||||
*
|
||||
* Detects changes between the current codebase state and a saved knowledge graph.
|
||||
* Used to determine when cognitive context needs to be regenerated or synced.
|
||||
*/
|
||||
|
||||
import type {
|
||||
KnowledgeGraph,
|
||||
DriftReport,
|
||||
StalenessLevel,
|
||||
ExtractedEntity,
|
||||
} from './types.js';
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
|
||||
/** Threshold for fresh classification (percentage of entities changed) */
|
||||
const FRESH_CHANGE_THRESHOLD = 0.05; // 5%
|
||||
|
||||
/** Threshold for stale classification (percentage of entities changed) */
|
||||
const STALE_CHANGE_THRESHOLD = 0.20; // 20%
|
||||
|
||||
/** Hours threshold for fresh classification */
|
||||
const FRESH_HOURS_THRESHOLD = 24;
|
||||
|
||||
/** Days threshold for stale classification */
|
||||
const STALE_DAYS_THRESHOLD = 7;
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
||||
export interface DriftDetectOptions {
|
||||
/**
|
||||
* Custom threshold for fresh classification (0-1)
|
||||
* @default 0.05 (5%)
|
||||
*/
|
||||
freshThreshold?: number;
|
||||
|
||||
/**
|
||||
* Custom threshold for stale classification (0-1)
|
||||
* @default 0.20 (20%)
|
||||
*/
|
||||
staleThreshold?: number;
|
||||
|
||||
/**
|
||||
* Custom hours threshold for fresh time-based classification
|
||||
* @default 24
|
||||
*/
|
||||
freshHoursThreshold?: number;
|
||||
|
||||
/**
|
||||
* Custom days threshold for stale time-based classification
|
||||
* @default 7
|
||||
*/
|
||||
staleDaysThreshold?: number;
|
||||
}
|
||||
|
||||
type EntityChangeType = 'added' | 'removed' | 'modified' | 'unchanged';
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Compare two entities and determine the type of change
|
||||
*
|
||||
* @param currentEntity - Entity from current graph (null if removed)
|
||||
* @param savedEntity - Entity from saved graph (null if added)
|
||||
* @returns The type of change detected
|
||||
*/
|
||||
function compareEntities(
|
||||
currentEntity: ExtractedEntity | null,
|
||||
savedEntity: ExtractedEntity | null
|
||||
): EntityChangeType {
|
||||
// Entity was added (exists in current but not in saved)
|
||||
if (currentEntity && !savedEntity) {
|
||||
return 'added';
|
||||
}
|
||||
|
||||
// Entity was removed (exists in saved but not in current)
|
||||
if (!currentEntity && savedEntity) {
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
// Both exist - compare by hash
|
||||
if (currentEntity && savedEntity) {
|
||||
if (currentEntity.hash !== savedEntity.hash) {
|
||||
return 'modified';
|
||||
}
|
||||
return 'unchanged';
|
||||
}
|
||||
|
||||
// Both null - shouldn't happen but handle gracefully
|
||||
return 'unchanged';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date from various formats (ISO string or Date object)
|
||||
*
|
||||
* @param dateValue - Date value to parse
|
||||
* @returns Parsed Date object
|
||||
*/
|
||||
function parseDate(dateValue: string | Date): Date {
|
||||
if (dateValue instanceof Date) {
|
||||
return dateValue;
|
||||
}
|
||||
return new Date(dateValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate hours between two dates
|
||||
*
|
||||
* @param from - Start date
|
||||
* @param to - End date
|
||||
* @returns Number of hours between dates
|
||||
*/
|
||||
function hoursBetween(from: Date, to: Date): number {
|
||||
const diffMs = to.getTime() - from.getTime();
|
||||
return diffMs / (1000 * 60 * 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate days between two dates
|
||||
*
|
||||
* @param from - Start date
|
||||
* @param to - End date
|
||||
* @returns Number of days between dates
|
||||
*/
|
||||
function daysBetween(from: Date, to: Date): number {
|
||||
return hoursBetween(from, to) / 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify staleness based on change percentage and time elapsed
|
||||
*
|
||||
* @param changePercentage - Percentage of entities that changed (0-1)
|
||||
* @param lastSync - Last sync timestamp
|
||||
* @param now - Current timestamp
|
||||
* @param options - Custom thresholds
|
||||
* @returns Staleness classification
|
||||
*/
|
||||
function classifyStaleness(
|
||||
changePercentage: number,
|
||||
lastSync: Date,
|
||||
now: Date,
|
||||
options: DriftDetectOptions = {}
|
||||
): StalenessLevel {
|
||||
const freshThreshold = options.freshThreshold ?? FRESH_CHANGE_THRESHOLD;
|
||||
const staleThreshold = options.staleThreshold ?? STALE_CHANGE_THRESHOLD;
|
||||
const freshHours = options.freshHoursThreshold ?? FRESH_HOURS_THRESHOLD;
|
||||
const staleDays = options.staleDaysThreshold ?? STALE_DAYS_THRESHOLD;
|
||||
|
||||
const hoursSinceSync = hoursBetween(lastSync, now);
|
||||
const daysSinceSync = daysBetween(lastSync, now);
|
||||
|
||||
// Critical: >20% changed OR >7 days since sync
|
||||
if (changePercentage > staleThreshold || daysSinceSync > staleDays) {
|
||||
return 'critical';
|
||||
}
|
||||
|
||||
// Stale: 5-20% changed OR 1-7 days since sync
|
||||
if (changePercentage > freshThreshold || hoursSinceSync > freshHours) {
|
||||
return 'stale';
|
||||
}
|
||||
|
||||
// Fresh: <5% changed AND within 24 hours
|
||||
return 'fresh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the recommended action based on staleness and changes
|
||||
*
|
||||
* @param staleness - Staleness classification
|
||||
* @param hasChanges - Whether any changes were detected
|
||||
* @returns Recommended action
|
||||
*/
|
||||
function determineRecommendation(
|
||||
staleness: StalenessLevel,
|
||||
hasChanges: boolean
|
||||
): 'none' | 'sync' | 'regenerate' {
|
||||
// No changes and fresh - no action needed
|
||||
if (!hasChanges && staleness === 'fresh') {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
// Critical staleness - full regeneration recommended
|
||||
if (staleness === 'critical') {
|
||||
return 'regenerate';
|
||||
}
|
||||
|
||||
// Stale with changes - sync recommended
|
||||
if (staleness === 'stale' || hasChanges) {
|
||||
return 'sync';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an entity lookup map from a knowledge graph
|
||||
*
|
||||
* @param graph - Knowledge graph to process
|
||||
* @returns Map of entity key to entity
|
||||
*/
|
||||
function createEntityMap(
|
||||
graph: KnowledgeGraph | null
|
||||
): Map<string, ExtractedEntity> {
|
||||
const map = new Map<string, ExtractedEntity>();
|
||||
|
||||
if (!graph || !graph.entities) {
|
||||
return map;
|
||||
}
|
||||
|
||||
// Entities are stored as Record<string, ExtractedEntity>
|
||||
for (const [key, entity] of Object.entries(graph.entities)) {
|
||||
if (entity) {
|
||||
map.set(key, entity);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique file paths from entities
|
||||
*
|
||||
* @param entities - Set of entity keys
|
||||
* @param entityMap - Map of entity key to entity
|
||||
* @returns Set of unique file paths
|
||||
*/
|
||||
function getUniquePaths(
|
||||
entities: Set<string>,
|
||||
entityMap: Map<string, ExtractedEntity>
|
||||
): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
|
||||
for (const key of entities) {
|
||||
const entity = entityMap.get(key);
|
||||
if (entity?.path) {
|
||||
paths.add(entity.path);
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Exports
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Detect drift between current codebase state and saved knowledge graph
|
||||
*
|
||||
* Compares entities by hash to identify additions, removals, and modifications.
|
||||
* Classifies staleness based on change percentage and time elapsed since last sync.
|
||||
*
|
||||
* @param currentGraph - Knowledge graph representing current codebase state
|
||||
* @param savedGraph - Knowledge graph from last sync (null for first run)
|
||||
* @param options - Custom detection thresholds
|
||||
* @returns Drift report with changes and recommendations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const current = await buildKnowledgeGraph('./src');
|
||||
* const saved = await loadSavedGraph('.cognitive/knowledge.json');
|
||||
* const drift = detectDrift(current, saved);
|
||||
*
|
||||
* if (drift.recommendation === 'regenerate') {
|
||||
* console.log('Major changes detected, regenerating context...');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function detectDrift(
|
||||
currentGraph: KnowledgeGraph | null,
|
||||
savedGraph: KnowledgeGraph | null,
|
||||
options: DriftDetectOptions = {}
|
||||
): DriftReport {
|
||||
const now = new Date();
|
||||
|
||||
// Handle first run (no saved graph) - treat as fresh
|
||||
if (!savedGraph) {
|
||||
const currentMap = createEntityMap(currentGraph);
|
||||
const entityKeys = Array.from(currentMap.keys());
|
||||
|
||||
return {
|
||||
filesChanged: 0,
|
||||
entitiesAdded: entityKeys,
|
||||
entitiesRemoved: [],
|
||||
entitiesModified: [],
|
||||
lastSync: now,
|
||||
staleness: 'fresh',
|
||||
recommendation: entityKeys.length > 0 ? 'sync' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle empty current graph (project cleared)
|
||||
if (!currentGraph) {
|
||||
const savedMap = createEntityMap(savedGraph);
|
||||
const entityKeys = Array.from(savedMap.keys());
|
||||
|
||||
return {
|
||||
filesChanged: entityKeys.length > 0 ? savedMap.size : 0,
|
||||
entitiesAdded: [],
|
||||
entitiesRemoved: entityKeys,
|
||||
entitiesModified: [],
|
||||
lastSync: parseDate(savedGraph.meta.generatedAt),
|
||||
staleness: entityKeys.length > 0 ? 'critical' : 'fresh',
|
||||
recommendation: entityKeys.length > 0 ? 'regenerate' : 'none',
|
||||
};
|
||||
}
|
||||
|
||||
// Create lookup maps for efficient comparison
|
||||
const currentMap = createEntityMap(currentGraph);
|
||||
const savedMap = createEntityMap(savedGraph);
|
||||
|
||||
// Collect all unique entity keys
|
||||
const allKeys = new Set<string>([...currentMap.keys(), ...savedMap.keys()]);
|
||||
|
||||
// Track changes
|
||||
const entitiesAdded: string[] = [];
|
||||
const entitiesRemoved: string[] = [];
|
||||
const entitiesModified: string[] = [];
|
||||
const changedEntities = new Set<string>();
|
||||
|
||||
// Compare each entity
|
||||
for (const key of allKeys) {
|
||||
const currentEntity = currentMap.get(key) ?? null;
|
||||
const savedEntity = savedMap.get(key) ?? null;
|
||||
|
||||
const changeType = compareEntities(currentEntity, savedEntity);
|
||||
|
||||
switch (changeType) {
|
||||
case 'added':
|
||||
entitiesAdded.push(key);
|
||||
changedEntities.add(key);
|
||||
break;
|
||||
case 'removed':
|
||||
entitiesRemoved.push(key);
|
||||
changedEntities.add(key);
|
||||
break;
|
||||
case 'modified':
|
||||
entitiesModified.push(key);
|
||||
changedEntities.add(key);
|
||||
break;
|
||||
// 'unchanged' - no action needed
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate unique files changed
|
||||
const currentChangedPaths = getUniquePaths(changedEntities, currentMap);
|
||||
const savedChangedPaths = getUniquePaths(changedEntities, savedMap);
|
||||
const allChangedPaths = new Set([...currentChangedPaths, ...savedChangedPaths]);
|
||||
const filesChanged = allChangedPaths.size;
|
||||
|
||||
// Calculate change percentage (based on total entities across both graphs)
|
||||
const totalEntities = Math.max(allKeys.size, 1); // Avoid division by zero
|
||||
const changePercentage = changedEntities.size / totalEntities;
|
||||
|
||||
// Determine last sync time
|
||||
const lastSync = parseDate(savedGraph.meta.generatedAt);
|
||||
|
||||
// Classify staleness
|
||||
const staleness = classifyStaleness(changePercentage, lastSync, now, options);
|
||||
|
||||
// Determine recommendation
|
||||
const hasChanges = changedEntities.size > 0;
|
||||
const recommendation = determineRecommendation(staleness, hasChanges);
|
||||
|
||||
return {
|
||||
filesChanged,
|
||||
entitiesAdded,
|
||||
entitiesRemoved,
|
||||
entitiesModified,
|
||||
lastSync,
|
||||
staleness,
|
||||
recommendation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a drift report indicates staleness requiring action
|
||||
*
|
||||
* @param report - Drift report to check
|
||||
* @returns True if the report indicates stale or critical staleness
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const drift = detectDrift(current, saved);
|
||||
* if (isStale(drift)) {
|
||||
* console.log('Context needs updating');
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isStale(report: DriftReport): boolean {
|
||||
return report.staleness !== 'fresh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a drift report indicates critical staleness
|
||||
*
|
||||
* @param report - Drift report to check
|
||||
* @returns True if the report indicates critical staleness
|
||||
*/
|
||||
export function isCritical(report: DriftReport): boolean {
|
||||
return report.staleness === 'critical';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any changes were detected
|
||||
*
|
||||
* @param report - Drift report to check
|
||||
* @returns True if any entities were added, removed, or modified
|
||||
*/
|
||||
export function hasChanges(report: DriftReport): boolean {
|
||||
return (
|
||||
report.entitiesAdded.length > 0 ||
|
||||
report.entitiesRemoved.length > 0 ||
|
||||
report.entitiesModified.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total number of changed entities
|
||||
*
|
||||
* @param report - Drift report to check
|
||||
* @returns Total count of added, removed, and modified entities
|
||||
*/
|
||||
export function getTotalChanges(report: DriftReport): number {
|
||||
return (
|
||||
report.entitiesAdded.length +
|
||||
report.entitiesRemoved.length +
|
||||
report.entitiesModified.length
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a drift report as a human-readable summary
|
||||
*
|
||||
* @param report - Drift report to format
|
||||
* @returns Formatted summary string
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const drift = detectDrift(current, saved);
|
||||
* console.log(formatDriftSummary(drift));
|
||||
* // Output:
|
||||
* // Drift Report
|
||||
* // ------------
|
||||
* // Status: stale
|
||||
* // Files changed: 5
|
||||
* // Entities added: 2
|
||||
* // Entities removed: 1
|
||||
* // Entities modified: 3
|
||||
* // Last sync: 2024-01-15T10:30:00.000Z
|
||||
* // Recommendation: sync
|
||||
* ```
|
||||
*/
|
||||
export function formatDriftSummary(report: DriftReport): string {
|
||||
const lines: string[] = [
|
||||
'Drift Report',
|
||||
'------------',
|
||||
`Status: ${report.staleness}`,
|
||||
`Files changed: ${report.filesChanged}`,
|
||||
`Entities added: ${report.entitiesAdded.length}`,
|
||||
`Entities removed: ${report.entitiesRemoved.length}`,
|
||||
`Entities modified: ${report.entitiesModified.length}`,
|
||||
`Last sync: ${report.lastSync.toISOString()}`,
|
||||
`Recommendation: ${report.recommendation}`,
|
||||
];
|
||||
|
||||
// Add details for non-empty changes
|
||||
if (report.entitiesAdded.length > 0 && report.entitiesAdded.length <= 10) {
|
||||
lines.push('', 'Added:');
|
||||
for (const entity of report.entitiesAdded) {
|
||||
lines.push(` + ${entity}`);
|
||||
}
|
||||
} else if (report.entitiesAdded.length > 10) {
|
||||
lines.push('', `Added: ${report.entitiesAdded.length} entities (showing first 10)`);
|
||||
for (const entity of report.entitiesAdded.slice(0, 10)) {
|
||||
lines.push(` + ${entity}`);
|
||||
}
|
||||
lines.push(` ... and ${report.entitiesAdded.length - 10} more`);
|
||||
}
|
||||
|
||||
if (report.entitiesRemoved.length > 0 && report.entitiesRemoved.length <= 10) {
|
||||
lines.push('', 'Removed:');
|
||||
for (const entity of report.entitiesRemoved) {
|
||||
lines.push(` - ${entity}`);
|
||||
}
|
||||
} else if (report.entitiesRemoved.length > 10) {
|
||||
lines.push('', `Removed: ${report.entitiesRemoved.length} entities (showing first 10)`);
|
||||
for (const entity of report.entitiesRemoved.slice(0, 10)) {
|
||||
lines.push(` - ${entity}`);
|
||||
}
|
||||
lines.push(` ... and ${report.entitiesRemoved.length - 10} more`);
|
||||
}
|
||||
|
||||
if (report.entitiesModified.length > 0 && report.entitiesModified.length <= 10) {
|
||||
lines.push('', 'Modified:');
|
||||
for (const entity of report.entitiesModified) {
|
||||
lines.push(` ~ ${entity}`);
|
||||
}
|
||||
} else if (report.entitiesModified.length > 10) {
|
||||
lines.push('', `Modified: ${report.entitiesModified.length} entities (showing first 10)`);
|
||||
for (const entity of report.entitiesModified.slice(0, 10)) {
|
||||
lines.push(` ~ ${entity}`);
|
||||
}
|
||||
lines.push(` ... and ${report.entitiesModified.length - 10} more`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a drift report as JSON-serializable object
|
||||
*
|
||||
* @param report - Drift report to format
|
||||
* @returns JSON-serializable drift report
|
||||
*/
|
||||
export function formatDriftJSON(report: DriftReport): Record<string, unknown> {
|
||||
return {
|
||||
staleness: report.staleness,
|
||||
recommendation: report.recommendation,
|
||||
filesChanged: report.filesChanged,
|
||||
changes: {
|
||||
added: report.entitiesAdded.length,
|
||||
removed: report.entitiesRemoved.length,
|
||||
modified: report.entitiesModified.length,
|
||||
total: getTotalChanges(report),
|
||||
},
|
||||
entities: {
|
||||
added: report.entitiesAdded,
|
||||
removed: report.entitiesRemoved,
|
||||
modified: report.entitiesModified,
|
||||
},
|
||||
lastSync: report.lastSync.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty drift report (for error cases or initialization)
|
||||
*
|
||||
* @returns Empty drift report with fresh status
|
||||
*/
|
||||
export function createEmptyDriftReport(): DriftReport {
|
||||
return {
|
||||
filesChanged: 0,
|
||||
entitiesAdded: [],
|
||||
entitiesRemoved: [],
|
||||
entitiesModified: [],
|
||||
lastSync: new Date(),
|
||||
staleness: 'fresh',
|
||||
recommendation: 'none',
|
||||
};
|
||||
}
|
||||
381
packages/cognitive-context/src/extractor.test.ts
Normal file
381
packages/cognitive-context/src/extractor.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Tests for the AST-based Entity Extractor
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { extractEntitiesFromFile, extractEntitiesFromDir } from './extractor.js';
|
||||
|
||||
const TEST_DIR = join(process.cwd(), '.test-fixtures');
|
||||
|
||||
// Test fixtures
|
||||
const fixtures = {
|
||||
reactComponent: `
|
||||
import React from 'react';
|
||||
|
||||
export interface ButtonProps {
|
||||
/** Button label text */
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ label, onClick, disabled }: ButtonProps) {
|
||||
return (
|
||||
<button onClick={onClick} disabled={disabled}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default Button;
|
||||
`,
|
||||
|
||||
customHook: `
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export interface UseCounterOptions {
|
||||
initialValue?: number;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export function useCounter(options: UseCounterOptions = {}) {
|
||||
const { initialValue = 0, step = 1 } = options;
|
||||
const [count, setCount] = useState(initialValue);
|
||||
|
||||
const increment = () => setCount(c => c + step);
|
||||
const decrement = () => setCount(c => c - step);
|
||||
const reset = () => setCount(initialValue);
|
||||
|
||||
return { count, increment, decrement, reset };
|
||||
}
|
||||
`,
|
||||
|
||||
utilityModule: `
|
||||
/**
|
||||
* String utility functions
|
||||
*/
|
||||
|
||||
export function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
export function slugify(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
export const DEFAULT_SEPARATOR = '-';
|
||||
`,
|
||||
|
||||
zodSchema: `
|
||||
import { z } from 'zod';
|
||||
|
||||
export const UserSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
name: z.string().min(1),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
|
||||
export type User = z.infer<typeof UserSchema>;
|
||||
|
||||
export const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
|
||||
`,
|
||||
|
||||
typesFile: `
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
status: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
|
||||
export interface RequestConfig {
|
||||
method: RequestMethod;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
`,
|
||||
|
||||
moduleWithImports: `
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { Config } from './types.js';
|
||||
import defaultConfig from '../config.js';
|
||||
|
||||
export async function loadConfig(path: string): Promise<Config> {
|
||||
const fullPath = resolve(process.cwd(), path);
|
||||
const content = await readFile(fullPath, 'utf-8');
|
||||
return { ...defaultConfig, ...JSON.parse(content) };
|
||||
}
|
||||
`,
|
||||
|
||||
defaultExportArrow: `
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Card = ({ title, children }: CardProps) => (
|
||||
<div className="card">
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Card;
|
||||
`,
|
||||
|
||||
classComponent: `
|
||||
import React, { Component } from 'react';
|
||||
|
||||
interface CounterState {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export class Counter extends Component<{}, CounterState> {
|
||||
state = { count: 0 };
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<span>{this.state.count}</span>
|
||||
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
describe('extractEntitiesFromFile', () => {
|
||||
beforeAll(async () => {
|
||||
await mkdir(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(TEST_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should extract React component with props', async () => {
|
||||
const filePath = join(TEST_DIR, 'Button.tsx');
|
||||
await writeFile(filePath, fixtures.reactComponent);
|
||||
|
||||
const entity = await extractEntitiesFromFile(filePath);
|
||||
|
||||
expect(entity).not.toBeNull();
|
||||
expect(entity!.name).toBe('Button');
|
||||
expect(entity!.type).toBe('component');
|
||||
expect(entity!.exports).toHaveLength(3); // ButtonProps, Button, default
|
||||
expect(entity!.hash).toMatch(/^[a-f0-9]{64}$/);
|
||||
|
||||
// Check exports
|
||||
const buttonExport = entity!.exports.find(e => e.name === 'Button' && !e.isDefault);
|
||||
expect(buttonExport).toBeDefined();
|
||||
expect(buttonExport!.kind).toBe('function');
|
||||
|
||||
const defaultExport = entity!.exports.find(e => e.isDefault);
|
||||
expect(defaultExport).toBeDefined();
|
||||
|
||||
// Check props extraction
|
||||
expect(entity!.props).toBeDefined();
|
||||
expect(entity!.props).toHaveLength(3);
|
||||
|
||||
const labelProp = entity!.props!.find(p => p.name === 'label');
|
||||
expect(labelProp).toBeDefined();
|
||||
expect(labelProp!.type).toBe('string');
|
||||
expect(labelProp!.required).toBe(true);
|
||||
|
||||
const onClickProp = entity!.props!.find(p => p.name === 'onClick');
|
||||
expect(onClickProp).toBeDefined();
|
||||
expect(onClickProp!.required).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect custom hooks by naming convention', async () => {
|
||||
const filePath = join(TEST_DIR, 'useCounter.ts');
|
||||
await writeFile(filePath, fixtures.customHook);
|
||||
|
||||
const entity = await extractEntitiesFromFile(filePath);
|
||||
|
||||
expect(entity).not.toBeNull();
|
||||
expect(entity!.name).toBe('useCounter');
|
||||
expect(entity!.type).toBe('hook');
|
||||
expect(entity!.exports.some(e => e.name === 'UseCounterOptions')).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract utility functions', async () => {
|
||||
const filePath = join(TEST_DIR, 'strings.ts');
|
||||
await writeFile(filePath, fixtures.utilityModule);
|
||||
|
||||
const entity = await extractEntitiesFromFile(filePath);
|
||||
|
||||
expect(entity).not.toBeNull();
|
||||
expect(entity!.type).toBe('utility');
|
||||
expect(entity!.exports).toHaveLength(3);
|
||||
|
||||
const names = entity!.exports.map(e => e.name);
|
||||
expect(names).toContain('capitalize');
|
||||
expect(names).toContain('slugify');
|
||||
expect(names).toContain('DEFAULT_SEPARATOR');
|
||||
});
|
||||
|
||||
it('should detect Zod schemas', async () => {
|
||||
const filePath = join(TEST_DIR, 'user.schema.ts');
|
||||
await writeFile(filePath, fixtures.zodSchema);
|
||||
|
||||
const entity = await extractEntitiesFromFile(filePath);
|
||||
|
||||
expect(entity).not.toBeNull();
|
||||
expect(entity!.type).toBe('schema');
|
||||
expect(entity!.imports.some(i => i.source === 'zod')).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect type-only files', async () => {
|
||||
const filePath = join(TEST_DIR, 'api.types.ts');
|
||||
await writeFile(filePath, fixtures.typesFile);
|
||||
|
||||
const entity = await extractEntitiesFromFile(filePath);
|
||||
|
||||
expect(entity).not.toBeNull();
|
||||
expect(entity!.type).toBe('interface');
|
||||
expect(entity!.exports.every(e => e.kind === 'interface' || e.kind === 'type')).toBe(true);
|
||||
});
|
||||
|
||||
it('should extract imports correctly', async () => {
|
||||
const filePath = join(TEST_DIR, 'config.loader.ts');
|
||||
await writeFile(filePath, fixtures.moduleWithImports);
|
||||
|
||||
const entity = await extractEntitiesFromFile(filePath);
|
||||
|
||||
expect(entity).not.toBeNull();
|
||||
expect(entity!.imports).toHaveLength(4);
|
||||
|
||||
// Check node:path import
|
||||
const pathImport = entity!.imports.find(i => i.source === 'node:path');
|
||||
expect(pathImport).toBeDefined();
|
||||
expect(pathImport!.isRelative).toBe(false);
|
||||
expect(pathImport!.specifiers).toContain('join');
|
||||
expect(pathImport!.specifiers).toContain('resolve');
|
||||
|
||||
// Check relative import
|
||||
const typesImport = entity!.imports.find(i => i.source === './types.js');
|
||||
expect(typesImport).toBeDefined();
|
||||
expect(typesImport!.isRelative).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle arrow function default exports', async () => {
|
||||
const filePath = join(TEST_DIR, 'Card.tsx');
|
||||
await writeFile(filePath, fixtures.defaultExportArrow);
|
||||
|
||||
const entity = await extractEntitiesFromFile(filePath);
|
||||
|
||||
expect(entity).not.toBeNull();
|
||||
expect(entity!.name).toBe('Card');
|
||||
expect(entity!.type).toBe('component');
|
||||
});
|
||||
|
||||
it('should handle class components', async () => {
|
||||
const filePath = join(TEST_DIR, 'Counter.tsx');
|
||||
await writeFile(filePath, fixtures.classComponent);
|
||||
|
||||
const entity = await extractEntitiesFromFile(filePath);
|
||||
|
||||
expect(entity).not.toBeNull();
|
||||
expect(entity!.type).toBe('component');
|
||||
expect(entity!.exports.some(e => e.kind === 'class' && e.name === 'Counter')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return null for non-existent files', async () => {
|
||||
const entity = await extractEntitiesFromFile('/non/existent/file.ts');
|
||||
expect(entity).toBeNull();
|
||||
});
|
||||
|
||||
it('should calculate consistent hashes', async () => {
|
||||
const filePath = join(TEST_DIR, 'hash-test.ts');
|
||||
await writeFile(filePath, fixtures.utilityModule);
|
||||
|
||||
const entity1 = await extractEntitiesFromFile(filePath);
|
||||
const entity2 = await extractEntitiesFromFile(filePath);
|
||||
|
||||
expect(entity1).not.toBeNull();
|
||||
expect(entity2).not.toBeNull();
|
||||
expect(entity1!.hash).toBe(entity2!.hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractEntitiesFromDir', () => {
|
||||
const DIR_TEST_PATH = join(TEST_DIR, 'dir-test');
|
||||
|
||||
beforeAll(async () => {
|
||||
await mkdir(DIR_TEST_PATH, { recursive: true });
|
||||
await mkdir(join(DIR_TEST_PATH, 'components'), { recursive: true });
|
||||
await mkdir(join(DIR_TEST_PATH, 'hooks'), { recursive: true });
|
||||
await mkdir(join(DIR_TEST_PATH, 'node_modules', 'some-package'), { recursive: true });
|
||||
|
||||
// Create test files
|
||||
await writeFile(join(DIR_TEST_PATH, 'components', 'Button.tsx'), fixtures.reactComponent);
|
||||
await writeFile(join(DIR_TEST_PATH, 'hooks', 'useCounter.ts'), fixtures.customHook);
|
||||
await writeFile(join(DIR_TEST_PATH, 'utils.ts'), fixtures.utilityModule);
|
||||
await writeFile(join(DIR_TEST_PATH, 'Button.test.tsx'), '// test file');
|
||||
await writeFile(join(DIR_TEST_PATH, 'node_modules', 'some-package', 'index.ts'), 'export const x = 1;');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(DIR_TEST_PATH, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('should extract all entities from directory recursively', async () => {
|
||||
const entities = await extractEntitiesFromDir(DIR_TEST_PATH);
|
||||
|
||||
expect(entities.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const names = entities.map(e => e.name);
|
||||
expect(names).toContain('Button');
|
||||
expect(names).toContain('useCounter');
|
||||
});
|
||||
|
||||
it('should exclude node_modules by default', async () => {
|
||||
const entities = await extractEntitiesFromDir(DIR_TEST_PATH);
|
||||
|
||||
// Should not include files from node_modules
|
||||
const nodeModuleEntity = entities.find(e => e.path.includes('node_modules'));
|
||||
expect(nodeModuleEntity).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should exclude test files by default', async () => {
|
||||
const entities = await extractEntitiesFromDir(DIR_TEST_PATH);
|
||||
|
||||
// Should not include test files
|
||||
const testEntity = entities.find(e => e.path.includes('.test.'));
|
||||
expect(testEntity).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should store relative paths', async () => {
|
||||
const entities = await extractEntitiesFromDir(DIR_TEST_PATH);
|
||||
|
||||
// All paths should be relative
|
||||
for (const entity of entities) {
|
||||
expect(entity.path.startsWith('/')).toBe(false);
|
||||
expect(entity.path.startsWith(DIR_TEST_PATH)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('should respect custom include patterns', async () => {
|
||||
const entities = await extractEntitiesFromDir(DIR_TEST_PATH, {
|
||||
include: ['**/hooks/**/*.ts'],
|
||||
exclude: [],
|
||||
});
|
||||
|
||||
expect(entities.length).toBe(1);
|
||||
expect(entities[0].name).toBe('useCounter');
|
||||
});
|
||||
});
|
||||
701
packages/cognitive-context/src/extractor.ts
Normal file
701
packages/cognitive-context/src/extractor.ts
Normal file
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* AST-based Entity Extractor
|
||||
*
|
||||
* Uses the TypeScript Compiler API to parse source files and extract
|
||||
* structured entity information for the Cognitive Context System.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { join, relative, extname } from 'node:path';
|
||||
import ts from 'typescript';
|
||||
import type {
|
||||
ExtractedEntity,
|
||||
ExtractedExport,
|
||||
ExtractedImport,
|
||||
EntityType,
|
||||
PropDefinition,
|
||||
} from './types.js';
|
||||
|
||||
// ============================================
|
||||
// Configuration
|
||||
// ============================================
|
||||
|
||||
const DEFAULT_INCLUDE = ['**/*.ts', '**/*.tsx'];
|
||||
const DEFAULT_EXCLUDE = [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/*.test.ts',
|
||||
'**/*.test.tsx',
|
||||
'**/*.spec.ts',
|
||||
'**/*.spec.tsx',
|
||||
'**/*.d.ts',
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Calculate SHA-256 hash of file content
|
||||
*/
|
||||
function calculateHash(content: string): string {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a name follows PascalCase convention (for component detection)
|
||||
*/
|
||||
function isPascalCase(name: string): boolean {
|
||||
return /^[A-Z][a-zA-Z0-9]*$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an identifier starts with 'use' (hook convention)
|
||||
*/
|
||||
function isHookName(name: string): boolean {
|
||||
return /^use[A-Z]/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path matches a glob-like pattern (simplified)
|
||||
*/
|
||||
function matchesPattern(path: string, pattern: string): boolean {
|
||||
// Normalize path separators
|
||||
const normalizedPath = path.replace(/\\/g, '/');
|
||||
const normalizedPattern = pattern.replace(/\\/g, '/');
|
||||
|
||||
// Convert glob to regex (simplified - handles ** and *)
|
||||
const regexPattern = normalizedPattern
|
||||
// Escape special regex chars (except * which we handle specially)
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
// Handle **/ at the start (match any prefix including nothing)
|
||||
.replace(/^\*\*\//, '(?:.*\\/)?')
|
||||
// Handle /**/ in the middle (match any number of directories)
|
||||
.replace(/\/\*\*\//g, '(?:\\/.*\\/|\\/)')
|
||||
// Handle /** at the end (match any suffix)
|
||||
.replace(/\/\*\*$/, '(?:\\/.*)?')
|
||||
// Handle remaining ** (match anything)
|
||||
.replace(/\*\*/g, '.*')
|
||||
// Handle single * (match anything except /)
|
||||
.replace(/\*/g, '[^/]*');
|
||||
|
||||
return new RegExp(`^${regexPattern}$`).test(normalizedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file should be included based on patterns
|
||||
*/
|
||||
function shouldIncludeFile(
|
||||
filePath: string,
|
||||
include: string[],
|
||||
exclude: string[]
|
||||
): boolean {
|
||||
// Check exclusions first
|
||||
for (const pattern of exclude) {
|
||||
if (matchesPattern(filePath, pattern)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Check inclusions
|
||||
for (const pattern of include) {
|
||||
if (matchesPattern(filePath, pattern)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if a node contains JSX
|
||||
*/
|
||||
function containsJsx(node: ts.Node): boolean {
|
||||
let hasJsx = false;
|
||||
|
||||
function visit(n: ts.Node): void {
|
||||
if (
|
||||
ts.isJsxElement(n) ||
|
||||
ts.isJsxSelfClosingElement(n) ||
|
||||
ts.isJsxFragment(n)
|
||||
) {
|
||||
hasJsx = true;
|
||||
return;
|
||||
}
|
||||
ts.forEachChild(n, visit);
|
||||
}
|
||||
|
||||
visit(node);
|
||||
return hasJsx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract type string from a TypeNode
|
||||
*/
|
||||
function typeNodeToString(typeNode: ts.TypeNode, sourceFile: ts.SourceFile): string {
|
||||
return typeNode.getText(sourceFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract props from a type reference or interface
|
||||
*/
|
||||
function extractPropsFromType(
|
||||
node: ts.Node,
|
||||
sourceFile: ts.SourceFile
|
||||
): PropDefinition[] | undefined {
|
||||
const props: PropDefinition[] = [];
|
||||
|
||||
// Handle interface declarations
|
||||
if (ts.isInterfaceDeclaration(node)) {
|
||||
for (const member of node.members) {
|
||||
if (ts.isPropertySignature(member) && member.name) {
|
||||
const name = member.name.getText(sourceFile);
|
||||
const type = member.type ? typeNodeToString(member.type, sourceFile) : 'unknown';
|
||||
const required = !member.questionToken;
|
||||
|
||||
// Extract JSDoc comment if present
|
||||
const jsDocComment = ts.getJSDocCommentsAndTags(member);
|
||||
let description: string | undefined;
|
||||
if (jsDocComment.length > 0) {
|
||||
const jsDoc = jsDocComment[0];
|
||||
if (ts.isJSDoc(jsDoc) && typeof jsDoc.comment === 'string') {
|
||||
description = jsDoc.comment;
|
||||
}
|
||||
}
|
||||
|
||||
props.push({ name, type, required, description });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle type literals
|
||||
if (ts.isTypeLiteralNode(node)) {
|
||||
for (const member of node.members) {
|
||||
if (ts.isPropertySignature(member) && member.name) {
|
||||
const name = member.name.getText(sourceFile);
|
||||
const type = member.type ? typeNodeToString(member.type, sourceFile) : 'unknown';
|
||||
const required = !member.questionToken;
|
||||
props.push({ name, type, required });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return props.length > 0 ? props : undefined;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// AST Extraction
|
||||
// ============================================
|
||||
|
||||
interface ExtractionContext {
|
||||
sourceFile: ts.SourceFile;
|
||||
exports: ExtractedExport[];
|
||||
imports: ExtractedImport[];
|
||||
propsInterface?: ts.InterfaceDeclaration;
|
||||
hasJsxExport: boolean;
|
||||
defaultExportName?: string;
|
||||
namedExportsWithJsx: Set<string>;
|
||||
/** Track variable declarations that contain JSX for default export detection */
|
||||
variablesWithJsx: Set<string>;
|
||||
/** Track class declarations that extend React.Component or contain JSX */
|
||||
classesWithJsx: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit all nodes in the source file to extract information
|
||||
*/
|
||||
function visitNode(node: ts.Node, ctx: ExtractionContext): void {
|
||||
const sourceFile = ctx.sourceFile;
|
||||
|
||||
// Handle import declarations
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
const moduleSpecifier = node.moduleSpecifier;
|
||||
if (ts.isStringLiteral(moduleSpecifier)) {
|
||||
const source = moduleSpecifier.text;
|
||||
const isRelative = source.startsWith('.') || source.startsWith('/');
|
||||
const specifiers: string[] = [];
|
||||
|
||||
if (node.importClause) {
|
||||
// Default import
|
||||
if (node.importClause.name) {
|
||||
specifiers.push(node.importClause.name.text);
|
||||
}
|
||||
// Named imports
|
||||
if (node.importClause.namedBindings) {
|
||||
if (ts.isNamedImports(node.importClause.namedBindings)) {
|
||||
for (const element of node.importClause.namedBindings.elements) {
|
||||
specifiers.push(element.name.text);
|
||||
}
|
||||
} else if (ts.isNamespaceImport(node.importClause.namedBindings)) {
|
||||
specifiers.push(`* as ${node.importClause.namedBindings.name.text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.imports.push({ source, specifiers, isRelative });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle export declarations
|
||||
if (ts.isExportDeclaration(node)) {
|
||||
// Named exports: export { a, b } or export { a } from './module'
|
||||
if (node.exportClause && ts.isNamedExports(node.exportClause)) {
|
||||
for (const element of node.exportClause.elements) {
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(element.getStart()).line + 1;
|
||||
ctx.exports.push({
|
||||
name: element.name.text,
|
||||
kind: 'const', // Default, could be refined
|
||||
isDefault: false,
|
||||
line,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle export assignment (export default X)
|
||||
if (ts.isExportAssignment(node) && !node.isExportEquals) {
|
||||
const expression = node.expression;
|
||||
let name = 'default';
|
||||
|
||||
if (ts.isIdentifier(expression)) {
|
||||
name = expression.text;
|
||||
ctx.defaultExportName = name;
|
||||
// Check if this identifier refers to a variable/class with JSX
|
||||
if (ctx.variablesWithJsx.has(name) || ctx.classesWithJsx.has(name)) {
|
||||
ctx.hasJsxExport = true;
|
||||
}
|
||||
} else if (ts.isFunctionExpression(expression) || ts.isArrowFunction(expression)) {
|
||||
if (containsJsx(expression)) {
|
||||
ctx.hasJsxExport = true;
|
||||
}
|
||||
}
|
||||
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
||||
ctx.exports.push({
|
||||
name,
|
||||
kind: 'const',
|
||||
isDefault: true,
|
||||
line,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle function declarations with export
|
||||
if (ts.isFunctionDeclaration(node) && node.name) {
|
||||
const hasExport = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
const isDefault = node.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword);
|
||||
|
||||
if (hasExport) {
|
||||
const name = node.name.text;
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
||||
|
||||
ctx.exports.push({
|
||||
name,
|
||||
kind: 'function',
|
||||
isDefault: !!isDefault,
|
||||
line,
|
||||
});
|
||||
|
||||
if (containsJsx(node)) {
|
||||
if (isDefault) {
|
||||
ctx.hasJsxExport = true;
|
||||
ctx.defaultExportName = name;
|
||||
} else {
|
||||
ctx.namedExportsWithJsx.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle class declarations with export
|
||||
if (ts.isClassDeclaration(node) && node.name) {
|
||||
const hasExport = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
const isDefault = node.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword);
|
||||
const className = node.name.text;
|
||||
|
||||
// Check if class contains JSX (for React class components)
|
||||
if (containsJsx(node)) {
|
||||
ctx.classesWithJsx.add(className);
|
||||
if (hasExport) {
|
||||
if (isDefault) {
|
||||
ctx.hasJsxExport = true;
|
||||
ctx.defaultExportName = className;
|
||||
} else {
|
||||
ctx.namedExportsWithJsx.add(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExport) {
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
||||
ctx.exports.push({
|
||||
name: className,
|
||||
kind: 'class',
|
||||
isDefault: !!isDefault,
|
||||
line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle variable statements (both exported and non-exported)
|
||||
if (ts.isVariableStatement(node)) {
|
||||
const hasExport = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
|
||||
for (const declaration of node.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
const name = declaration.name.text;
|
||||
|
||||
// Check if it's an arrow function or function expression with JSX
|
||||
let kind: ExtractedExport['kind'] = 'const';
|
||||
let hasJsx = false;
|
||||
if (declaration.initializer) {
|
||||
if (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) {
|
||||
kind = 'function';
|
||||
if (containsJsx(declaration.initializer)) {
|
||||
hasJsx = true;
|
||||
ctx.variablesWithJsx.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExport) {
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(declaration.getStart()).line + 1;
|
||||
if (hasJsx) {
|
||||
ctx.namedExportsWithJsx.add(name);
|
||||
}
|
||||
ctx.exports.push({
|
||||
name,
|
||||
kind,
|
||||
isDefault: false,
|
||||
line,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle interface declarations with export
|
||||
if (ts.isInterfaceDeclaration(node)) {
|
||||
const hasExport = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
const name = node.name.text;
|
||||
|
||||
if (hasExport) {
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
||||
ctx.exports.push({
|
||||
name,
|
||||
kind: 'interface',
|
||||
isDefault: false,
|
||||
line,
|
||||
});
|
||||
}
|
||||
|
||||
// Track Props interfaces for component prop extraction
|
||||
if (name.endsWith('Props')) {
|
||||
ctx.propsInterface = node;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle type alias declarations with export
|
||||
if (ts.isTypeAliasDeclaration(node)) {
|
||||
const hasExport = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
|
||||
if (hasExport) {
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
||||
ctx.exports.push({
|
||||
name: node.name.text,
|
||||
kind: 'type',
|
||||
isDefault: false,
|
||||
line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle enum declarations with export
|
||||
if (ts.isEnumDeclaration(node)) {
|
||||
const hasExport = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
|
||||
if (hasExport) {
|
||||
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
||||
ctx.exports.push({
|
||||
name: node.name.text,
|
||||
kind: 'enum',
|
||||
isDefault: false,
|
||||
line,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into children
|
||||
ts.forEachChild(node, child => visitNode(child, ctx));
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the entity type based on extracted information
|
||||
*/
|
||||
function determineEntityType(
|
||||
ctx: ExtractionContext,
|
||||
primaryName: string,
|
||||
sourceFile: ts.SourceFile
|
||||
): EntityType {
|
||||
const fileName = sourceFile.fileName.toLowerCase();
|
||||
|
||||
// Check for hook (use* naming convention)
|
||||
if (isHookName(primaryName)) {
|
||||
return 'hook';
|
||||
}
|
||||
|
||||
// Check for React component (JSX + PascalCase)
|
||||
if (ctx.hasJsxExport && isPascalCase(primaryName)) {
|
||||
return 'component';
|
||||
}
|
||||
|
||||
// Check named exports for components
|
||||
for (const name of ctx.namedExportsWithJsx) {
|
||||
if (isPascalCase(name)) {
|
||||
return 'component';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Zod schema
|
||||
const hasZodImport = ctx.imports.some(i => i.source === 'zod' || i.source.startsWith('zod/'));
|
||||
if (hasZodImport) {
|
||||
return 'schema';
|
||||
}
|
||||
|
||||
// Check for API endpoint patterns
|
||||
if (fileName.includes('/api/') || fileName.includes('route.')) {
|
||||
return 'endpoint';
|
||||
}
|
||||
|
||||
// Check for type/interface files
|
||||
const hasOnlyTypeExports = ctx.exports.every(e => e.kind === 'type' || e.kind === 'interface');
|
||||
if (hasOnlyTypeExports && ctx.exports.length > 0) {
|
||||
const hasInterface = ctx.exports.some(e => e.kind === 'interface');
|
||||
return hasInterface ? 'interface' : 'type';
|
||||
}
|
||||
|
||||
// Check for utility functions
|
||||
if (ctx.exports.some(e => e.kind === 'function') && !ctx.hasJsxExport) {
|
||||
return 'utility';
|
||||
}
|
||||
|
||||
// Default to module
|
||||
return 'module';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Public API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Extract entities from a single TypeScript/TSX file
|
||||
*
|
||||
* @param filePath - Absolute path to the file
|
||||
* @returns Extracted entity information, or null if parsing fails
|
||||
*/
|
||||
export async function extractEntitiesFromFile(
|
||||
filePath: string
|
||||
): Promise<ExtractedEntity | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const hash = calculateHash(content);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// Determine script kind based on extension
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
let scriptKind: ts.ScriptKind;
|
||||
switch (ext) {
|
||||
case '.tsx':
|
||||
scriptKind = ts.ScriptKind.TSX;
|
||||
break;
|
||||
case '.ts':
|
||||
scriptKind = ts.ScriptKind.TS;
|
||||
break;
|
||||
case '.jsx':
|
||||
scriptKind = ts.ScriptKind.JSX;
|
||||
break;
|
||||
case '.js':
|
||||
scriptKind = ts.ScriptKind.JS;
|
||||
break;
|
||||
default:
|
||||
scriptKind = ts.ScriptKind.TS;
|
||||
}
|
||||
|
||||
// Parse the source file
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
content,
|
||||
ts.ScriptTarget.ESNext,
|
||||
true,
|
||||
scriptKind
|
||||
);
|
||||
|
||||
// Create extraction context
|
||||
const ctx: ExtractionContext = {
|
||||
sourceFile,
|
||||
exports: [],
|
||||
imports: [],
|
||||
hasJsxExport: false,
|
||||
namedExportsWithJsx: new Set(),
|
||||
variablesWithJsx: new Set(),
|
||||
classesWithJsx: new Set(),
|
||||
};
|
||||
|
||||
// Visit all nodes
|
||||
ts.forEachChild(sourceFile, node => visitNode(node, ctx));
|
||||
|
||||
// Determine primary name (default export or first named export)
|
||||
// Priority: default export > function exports > other exports
|
||||
let primaryName = 'unknown';
|
||||
const defaultExport = ctx.exports.find(e => e.isDefault);
|
||||
if (defaultExport) {
|
||||
primaryName = defaultExport.name !== 'default' ? defaultExport.name : ctx.defaultExportName || 'default';
|
||||
} else if (ctx.exports.length > 0) {
|
||||
// Prefer function exports over type/interface exports for naming
|
||||
const functionExport = ctx.exports.find(e => e.kind === 'function');
|
||||
const classExport = ctx.exports.find(e => e.kind === 'class');
|
||||
if (functionExport) {
|
||||
primaryName = functionExport.name;
|
||||
} else if (classExport) {
|
||||
primaryName = classExport.name;
|
||||
} else {
|
||||
primaryName = ctx.exports[0].name;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine entity type
|
||||
const entityType = determineEntityType(ctx, primaryName, sourceFile);
|
||||
|
||||
// Extract props if it's a component
|
||||
let props: PropDefinition[] | undefined;
|
||||
if (entityType === 'component' && ctx.propsInterface) {
|
||||
props = extractPropsFromType(ctx.propsInterface, sourceFile);
|
||||
}
|
||||
|
||||
return {
|
||||
name: primaryName,
|
||||
path: filePath,
|
||||
type: entityType,
|
||||
exports: ctx.exports,
|
||||
imports: ctx.imports,
|
||||
props,
|
||||
modifiedAt: stats.mtime,
|
||||
hash,
|
||||
};
|
||||
} catch (error) {
|
||||
// Return null for unparseable files
|
||||
console.error(`Failed to extract entities from ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory should be excluded based on patterns
|
||||
*/
|
||||
function shouldExcludeDirectory(relativePath: string, exclude: string[]): boolean {
|
||||
const normalizedPath = relativePath.replace(/\\/g, '/');
|
||||
|
||||
for (const pattern of exclude) {
|
||||
const normalizedPattern = pattern.replace(/\\/g, '/');
|
||||
|
||||
// Handle patterns like **/node_modules/** - extract the directory name
|
||||
const match = normalizedPattern.match(/^\*\*\/([^/*]+)\/\*\*$/);
|
||||
if (match) {
|
||||
const dirName = match[1];
|
||||
// Check if any part of the path matches this directory name
|
||||
const parts = normalizedPath.split('/');
|
||||
if (parts.includes(dirName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the directory path matches the pattern directly
|
||||
if (matchesPattern(normalizedPath, normalizedPattern)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if appending /** would match (for directory patterns)
|
||||
if (matchesPattern(normalizedPath + '/', normalizedPattern.replace(/\*\*$/, ''))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively get all TypeScript files in a directory
|
||||
*/
|
||||
async function getFilesRecursively(
|
||||
dir: string,
|
||||
include: string[],
|
||||
exclude: string[],
|
||||
baseDir: string
|
||||
): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
const relativePath = relative(baseDir, fullPath);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
// Check if directory should be excluded
|
||||
if (!shouldExcludeDirectory(relativePath, exclude)) {
|
||||
const subFiles = await getFilesRecursively(fullPath, include, exclude, baseDir);
|
||||
files.push(...subFiles);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
|
||||
if (shouldIncludeFile(relativePath, include, exclude)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export interface ExtractOptions {
|
||||
/**
|
||||
* Glob patterns for files to include
|
||||
* @default ['**\/*.ts', '**\/*.tsx']
|
||||
*/
|
||||
include?: string[];
|
||||
|
||||
/**
|
||||
* Glob patterns for files to exclude
|
||||
* @default ['**\/node_modules\/**', '**\/dist\/**', '**\/*.test.ts', ...]
|
||||
*/
|
||||
exclude?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract entities from all TypeScript files in a directory
|
||||
*
|
||||
* @param dir - Directory to scan
|
||||
* @param options - Include/exclude patterns
|
||||
* @returns Array of extracted entities
|
||||
*/
|
||||
export async function extractEntitiesFromDir(
|
||||
dir: string,
|
||||
options: ExtractOptions = {}
|
||||
): Promise<ExtractedEntity[]> {
|
||||
const include = options.include ?? DEFAULT_INCLUDE;
|
||||
const exclude = options.exclude ?? DEFAULT_EXCLUDE;
|
||||
|
||||
const files = await getFilesRecursively(dir, include, exclude, dir);
|
||||
const entities: ExtractedEntity[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const entity = await extractEntitiesFromFile(file);
|
||||
if (entity) {
|
||||
// Convert absolute path to relative for storage
|
||||
entity.path = relative(dir, file);
|
||||
entities.push(entity);
|
||||
}
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
7
packages/cognitive-context/src/hooks/index.ts
Normal file
7
packages/cognitive-context/src/hooks/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Git hooks for Cognitive Context
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
export * from './pre-commit.js';
|
||||
177
packages/cognitive-context/src/hooks/pre-commit.ts
Normal file
177
packages/cognitive-context/src/hooks/pre-commit.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Pre-commit Hook for Cognitive Context
|
||||
*
|
||||
* Validates cognitive context before commits to ensure documentation stays in sync.
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, chmod, rm, access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { detectDrift, isStale, isCritical } from '../drift.js';
|
||||
import { syncAll } from '../sync.js';
|
||||
import { loadConfigOrDefault } from '../config.js';
|
||||
import type { KnowledgeGraph, DriftReport } from '../types.js';
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
||||
export interface PreCommitOptions {
|
||||
blockOnStale?: boolean; // Block commit if context is stale
|
||||
autoSync?: boolean; // Auto-sync if drift detected
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
export interface PreCommitResult {
|
||||
passed: boolean;
|
||||
driftDetected: boolean;
|
||||
filesChanged: number;
|
||||
message: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
|
||||
const HOOK_SCRIPT = `#!/bin/sh
|
||||
# Cognitive Context Pre-commit Hook
|
||||
npx cognitive pre-commit
|
||||
exit $?
|
||||
`;
|
||||
|
||||
const COGNITIVE_DIR = '.cognitive';
|
||||
const KNOWLEDGE_FILE = 'knowledge.json';
|
||||
|
||||
// ============================================
|
||||
// Helpers
|
||||
// ============================================
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try { await access(path); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function loadSavedGraph(projectRoot: string): Promise<KnowledgeGraph | null> {
|
||||
try {
|
||||
const content = await readFile(join(projectRoot, COGNITIVE_DIR, KNOWLEDGE_FILE), 'utf-8');
|
||||
return JSON.parse(content) as KnowledgeGraph;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function buildRecommendation(report: DriftReport): string {
|
||||
if (report.recommendation === 'regenerate') return 'Run `cognitive extract && cognitive sync`';
|
||||
if (report.recommendation === 'sync') return 'Run `cognitive sync` to update tool files';
|
||||
return 'No action needed';
|
||||
}
|
||||
|
||||
function buildMessage(report: DriftReport, verbose: boolean): string {
|
||||
if (report.staleness === 'fresh' && report.recommendation === 'none') {
|
||||
return 'Cognitive context is up to date.';
|
||||
}
|
||||
const changes = report.entitiesAdded.length + report.entitiesRemoved.length + report.entitiesModified.length;
|
||||
const lines = [
|
||||
`Cognitive context drift detected:`,
|
||||
` Status: ${report.staleness}`,
|
||||
` Files changed: ${report.filesChanged}`,
|
||||
` Entity changes: ${changes}`,
|
||||
];
|
||||
if (verbose) {
|
||||
if (report.entitiesAdded.length) lines.push(` Added: ${report.entitiesAdded.length}`);
|
||||
if (report.entitiesRemoved.length) lines.push(` Removed: ${report.entitiesRemoved.length}`);
|
||||
if (report.entitiesModified.length) lines.push(` Modified: ${report.entitiesModified.length}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Exports
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Run pre-commit validation check
|
||||
*/
|
||||
export async function runPreCommitCheck(
|
||||
projectRoot: string,
|
||||
options: PreCommitOptions = {}
|
||||
): Promise<PreCommitResult> {
|
||||
const { blockOnStale = false, autoSync = false, verbose = false } = options;
|
||||
|
||||
const savedGraph = await loadSavedGraph(projectRoot);
|
||||
if (!savedGraph) {
|
||||
return {
|
||||
passed: true,
|
||||
driftDetected: false,
|
||||
filesChanged: 0,
|
||||
message: 'No cognitive context found. Run `cognitive extract` to initialize.',
|
||||
recommendation: 'Run `cognitive extract && cognitive sync` to set up context',
|
||||
};
|
||||
}
|
||||
|
||||
const report = detectDrift(savedGraph, savedGraph);
|
||||
const driftDetected = isStale(report);
|
||||
const criticallyStale = isCritical(report);
|
||||
|
||||
if (autoSync && driftDetected) {
|
||||
try {
|
||||
const config = await loadConfigOrDefault(projectRoot);
|
||||
const toolConfigs = config.tools.map((t) => ({
|
||||
name: t.name, enabled: t.enabled, outputPath: t.outputPath ?? '',
|
||||
}));
|
||||
await syncAll(projectRoot, toolConfigs, { verbose });
|
||||
} catch { /* continue with warning */ }
|
||||
}
|
||||
|
||||
return {
|
||||
passed: !blockOnStale || !driftDetected || (blockOnStale && !criticallyStale),
|
||||
driftDetected,
|
||||
filesChanged: report.filesChanged,
|
||||
message: buildMessage(report, verbose),
|
||||
recommendation: buildRecommendation(report),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format pre-commit result for console output
|
||||
*/
|
||||
export function formatPreCommitOutput(result: PreCommitResult): string {
|
||||
const icon = result.passed ? '[OK]' : '[BLOCKED]';
|
||||
const lines = [`${icon} Cognitive Context Pre-commit Check`, '', result.message];
|
||||
if (result.driftDetected) lines.push('', `Recommendation: ${result.recommendation}`);
|
||||
if (!result.passed) {
|
||||
lines.push('', 'Commit blocked due to critically stale context.');
|
||||
lines.push('Update context or use --no-verify to skip.');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the pre-commit hook into .git/hooks
|
||||
*/
|
||||
export async function installHook(projectRoot: string): Promise<void> {
|
||||
const hookPath = join(projectRoot, '.git', 'hooks', 'pre-commit');
|
||||
|
||||
if (!(await fileExists(join(projectRoot, '.git')))) {
|
||||
throw new Error('Not a git repository: .git directory not found');
|
||||
}
|
||||
if (await fileExists(hookPath)) {
|
||||
const content = await readFile(hookPath, 'utf-8');
|
||||
if (content.includes('cognitive')) throw new Error('Cognitive hook already installed');
|
||||
throw new Error('Pre-commit hook exists. Remove it or add cognitive check manually.');
|
||||
}
|
||||
|
||||
await writeFile(hookPath, HOOK_SCRIPT, 'utf-8');
|
||||
await chmod(hookPath, 0o755);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall the pre-commit hook from .git/hooks
|
||||
*/
|
||||
export async function uninstallHook(projectRoot: string): Promise<void> {
|
||||
const hookPath = join(projectRoot, '.git', 'hooks', 'pre-commit');
|
||||
if (!(await fileExists(hookPath))) return;
|
||||
|
||||
const content = await readFile(hookPath, 'utf-8');
|
||||
if (!content.includes('cognitive')) {
|
||||
throw new Error('Pre-commit hook was not installed by cognitive-context');
|
||||
}
|
||||
await rm(hookPath);
|
||||
}
|
||||
34
packages/cognitive-context/src/index.ts
Normal file
34
packages/cognitive-context/src/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Cognitive Context System
|
||||
*
|
||||
* Production-ready context management for AI coding assistants.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from './types.js';
|
||||
|
||||
// Configuration
|
||||
export * from './config.schema.js';
|
||||
export * from './config.js';
|
||||
|
||||
// Core modules (to be implemented in Wave 1-3)
|
||||
export * from './extractor.js';
|
||||
export * from './watcher.js';
|
||||
export * from './tokens.js';
|
||||
export * from './validator.js';
|
||||
export * from './sync.js';
|
||||
export * from './drift.js';
|
||||
|
||||
// Adapters
|
||||
export * from './adapters/index.js';
|
||||
|
||||
// Hooks
|
||||
export * from './hooks/index.js';
|
||||
|
||||
// CLI (exported for programmatic use)
|
||||
export { createProgram } from './cli.js';
|
||||
|
||||
// Version
|
||||
export const VERSION = '0.1.0';
|
||||
1093
packages/cognitive-context/src/sync.ts
Normal file
1093
packages/cognitive-context/src/sync.ts
Normal file
File diff suppressed because it is too large
Load Diff
374
packages/cognitive-context/src/tokens.test.ts
Normal file
374
packages/cognitive-context/src/tokens.test.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Tests for token counting module
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { writeFile, unlink, mkdir, rmdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import {
|
||||
countTokens,
|
||||
countFileTokens,
|
||||
validateBudget,
|
||||
truncateToTokenLimit,
|
||||
createTokenReport,
|
||||
getBudgetForFile,
|
||||
formatTokenCount,
|
||||
resetEncoder,
|
||||
} from './tokens.js';
|
||||
import type { TokenBudget, TokenReport } from './types.js';
|
||||
|
||||
// ============================================
|
||||
// Test Setup
|
||||
// ============================================
|
||||
|
||||
describe('tokens', () => {
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetEncoder();
|
||||
testDir = join(tmpdir(), `cognitive-test-${Date.now()}`);
|
||||
await mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
// Clean up test files
|
||||
const files = ['test.txt', 'empty.txt', 'summary.md'];
|
||||
for (const file of files) {
|
||||
try {
|
||||
await unlink(join(testDir, file));
|
||||
} catch {
|
||||
// Ignore if file doesn't exist
|
||||
}
|
||||
}
|
||||
await rmdir(testDir);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// countTokens Tests
|
||||
// ============================================
|
||||
|
||||
describe('countTokens', () => {
|
||||
it('should count tokens in a simple string', () => {
|
||||
const text = 'Hello, world!';
|
||||
const count = countTokens(text);
|
||||
|
||||
// cl100k_base tokenizes "Hello, world!" into about 4 tokens
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(count).toBeLessThan(10);
|
||||
});
|
||||
|
||||
it('should return 0 for empty string', () => {
|
||||
expect(countTokens('')).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 0 for null/undefined-like input', () => {
|
||||
// @ts-expect-error Testing edge case
|
||||
expect(countTokens(null)).toBe(0);
|
||||
// @ts-expect-error Testing edge case
|
||||
expect(countTokens(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle long text', () => {
|
||||
const longText = 'This is a test sentence. '.repeat(100);
|
||||
const count = countTokens(longText);
|
||||
|
||||
// Should be roughly proportional to length
|
||||
expect(count).toBeGreaterThan(100);
|
||||
expect(count).toBeLessThan(1000);
|
||||
});
|
||||
|
||||
it('should handle special characters and unicode', () => {
|
||||
const text = 'Hello! 你好! مرحبا! 🌍';
|
||||
const count = countTokens(text);
|
||||
|
||||
// Should handle unicode without throwing
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle code snippets', () => {
|
||||
const code = `
|
||||
function hello() {
|
||||
console.log("Hello, world!");
|
||||
return 42;
|
||||
}
|
||||
`;
|
||||
const count = countTokens(code);
|
||||
|
||||
expect(count).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// countFileTokens Tests
|
||||
// ============================================
|
||||
|
||||
describe('countFileTokens', () => {
|
||||
it('should count tokens in a file', async () => {
|
||||
const filePath = join(testDir, 'test.txt');
|
||||
const content = 'This is test content for token counting.';
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
|
||||
const report = await countFileTokens(filePath, 100);
|
||||
|
||||
expect(report.file).toBe(filePath);
|
||||
expect(report.tokens).toBeGreaterThan(0);
|
||||
expect(report.budget).toBe(100);
|
||||
expect(report.overBudget).toBe(false);
|
||||
expect(report.percentage).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should detect when file is over budget', async () => {
|
||||
const filePath = join(testDir, 'test.txt');
|
||||
const content = 'This is a longer test content. '.repeat(50);
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
|
||||
const report = await countFileTokens(filePath, 10);
|
||||
|
||||
expect(report.overBudget).toBe(true);
|
||||
expect(report.percentage).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('should handle non-existent file gracefully', async () => {
|
||||
const report = await countFileTokens('/non/existent/file.txt', 100);
|
||||
|
||||
expect(report.tokens).toBe(0);
|
||||
expect(report.overBudget).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty file', async () => {
|
||||
const filePath = join(testDir, 'empty.txt');
|
||||
await writeFile(filePath, '', 'utf-8');
|
||||
|
||||
const report = await countFileTokens(filePath, 100);
|
||||
|
||||
expect(report.tokens).toBe(0);
|
||||
expect(report.overBudget).toBe(false);
|
||||
expect(report.percentage).toBe(0);
|
||||
});
|
||||
|
||||
it('should use Infinity as default budget', async () => {
|
||||
const filePath = join(testDir, 'test.txt');
|
||||
await writeFile(filePath, 'Some content', 'utf-8');
|
||||
|
||||
const report = await countFileTokens(filePath);
|
||||
|
||||
expect(report.budget).toBe(Infinity);
|
||||
expect(report.overBudget).toBe(false);
|
||||
expect(report.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// validateBudget Tests
|
||||
// ============================================
|
||||
|
||||
describe('validateBudget', () => {
|
||||
const defaultBudget: TokenBudget = {
|
||||
summary: 300,
|
||||
capabilities: 2000,
|
||||
wisdomPerFile: 1500,
|
||||
total: 5000,
|
||||
};
|
||||
|
||||
it('should return valid when all reports are within budget', () => {
|
||||
const reports: TokenReport[] = [
|
||||
{ file: 'SUMMARY.md', tokens: 200, budget: 300, overBudget: false, percentage: 67 },
|
||||
{ file: 'capabilities.yaml', tokens: 1000, budget: 2000, overBudget: false, percentage: 50 },
|
||||
];
|
||||
|
||||
const result = validateBudget(reports, defaultBudget);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect individual file budget violations', () => {
|
||||
const reports: TokenReport[] = [
|
||||
{ file: 'SUMMARY.md', tokens: 400, budget: 300, overBudget: true, percentage: 133 },
|
||||
{ file: 'capabilities.yaml', tokens: 1000, budget: 2000, overBudget: false, percentage: 50 },
|
||||
];
|
||||
|
||||
const result = validateBudget(reports, defaultBudget);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.violations).toHaveLength(1);
|
||||
expect(result.violations[0].file).toBe('SUMMARY.md');
|
||||
});
|
||||
|
||||
it('should detect total budget violations', () => {
|
||||
const reports: TokenReport[] = [
|
||||
{ file: 'file1.md', tokens: 3000, budget: 5000, overBudget: false, percentage: 60 },
|
||||
{ file: 'file2.md', tokens: 3000, budget: 5000, overBudget: false, percentage: 60 },
|
||||
];
|
||||
|
||||
const result = validateBudget(reports, defaultBudget);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
// Should have total violation
|
||||
const totalViolation = result.violations.find((v) => v.file === '[TOTAL]');
|
||||
expect(totalViolation).toBeDefined();
|
||||
expect(totalViolation?.tokens).toBe(6000);
|
||||
});
|
||||
|
||||
it('should handle empty reports array', () => {
|
||||
const result = validateBudget([], defaultBudget);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.violations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect multiple violations', () => {
|
||||
const reports: TokenReport[] = [
|
||||
{ file: 'SUMMARY.md', tokens: 500, budget: 300, overBudget: true, percentage: 167 },
|
||||
{ file: 'wisdom/guide.md', tokens: 2000, budget: 1500, overBudget: true, percentage: 133 },
|
||||
];
|
||||
|
||||
const result = validateBudget(reports, defaultBudget);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.violations.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// truncateToTokenLimit Tests
|
||||
// ============================================
|
||||
|
||||
describe('truncateToTokenLimit', () => {
|
||||
it('should return text unchanged if within limit', () => {
|
||||
const text = 'Hello, world!';
|
||||
const result = truncateToTokenLimit(text, 100);
|
||||
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it('should truncate text to fit within limit', () => {
|
||||
const text = 'This is a test sentence. '.repeat(100);
|
||||
const result = truncateToTokenLimit(text, 50);
|
||||
|
||||
const resultTokens = countTokens(result);
|
||||
expect(resultTokens).toBeLessThanOrEqual(50);
|
||||
expect(result.length).toBeLessThan(text.length);
|
||||
});
|
||||
|
||||
it('should return empty string for empty input', () => {
|
||||
expect(truncateToTokenLimit('', 100)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for zero max tokens', () => {
|
||||
expect(truncateToTokenLimit('Hello', 0)).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for negative max tokens', () => {
|
||||
expect(truncateToTokenLimit('Hello', -10)).toBe('');
|
||||
});
|
||||
|
||||
it('should try to break at sentence boundaries', () => {
|
||||
const text = 'First sentence. Second sentence. Third sentence.';
|
||||
const result = truncateToTokenLimit(text, 8);
|
||||
|
||||
// Should end with a period if it found a sentence boundary
|
||||
if (result.length > 10) {
|
||||
expect(result.endsWith('.') || result.endsWith('sentence')).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle single word exceeding limit', () => {
|
||||
const text = 'supercalifragilisticexpialidocious';
|
||||
const result = truncateToTokenLimit(text, 1);
|
||||
|
||||
// Should still return something (partial word)
|
||||
expect(countTokens(result)).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// createTokenReport Tests
|
||||
// ============================================
|
||||
|
||||
describe('createTokenReport', () => {
|
||||
it('should create a report with correct values', () => {
|
||||
const report = createTokenReport('test.md', 'Hello world', 100);
|
||||
|
||||
expect(report.file).toBe('test.md');
|
||||
expect(report.tokens).toBeGreaterThan(0);
|
||||
expect(report.budget).toBe(100);
|
||||
expect(report.overBudget).toBe(false);
|
||||
expect(report.percentage).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should detect over budget content', () => {
|
||||
const content = 'This is content. '.repeat(100);
|
||||
const report = createTokenReport('test.md', content, 10);
|
||||
|
||||
expect(report.overBudget).toBe(true);
|
||||
expect(report.percentage).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('should handle zero budget', () => {
|
||||
const report = createTokenReport('test.md', 'content', 0);
|
||||
|
||||
expect(report.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// getBudgetForFile Tests
|
||||
// ============================================
|
||||
|
||||
describe('getBudgetForFile', () => {
|
||||
const budget: TokenBudget = {
|
||||
summary: 300,
|
||||
capabilities: 2000,
|
||||
wisdomPerFile: 1500,
|
||||
total: 20000,
|
||||
};
|
||||
|
||||
it('should return summary budget for summary files', () => {
|
||||
expect(getBudgetForFile('SUMMARY.md', budget)).toBe(300);
|
||||
expect(getBudgetForFile('.context/SUMMARY.md', budget)).toBe(300);
|
||||
expect(getBudgetForFile('docs/summary.txt', budget)).toBe(300);
|
||||
});
|
||||
|
||||
it('should return capabilities budget for capabilities files', () => {
|
||||
expect(getBudgetForFile('capabilities.yaml', budget)).toBe(2000);
|
||||
expect(getBudgetForFile('.context/capabilities.yml', budget)).toBe(2000);
|
||||
});
|
||||
|
||||
it('should return wisdom budget for wisdom files', () => {
|
||||
expect(getBudgetForFile('wisdom/guide.md', budget)).toBe(1500);
|
||||
expect(getBudgetForFile('.context/wisdom/patterns.md', budget)).toBe(1500);
|
||||
});
|
||||
|
||||
it('should return total budget for unknown files', () => {
|
||||
expect(getBudgetForFile('random.md', budget)).toBe(20000);
|
||||
expect(getBudgetForFile('docs/other.txt', budget)).toBe(20000);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// formatTokenCount Tests
|
||||
// ============================================
|
||||
|
||||
describe('formatTokenCount', () => {
|
||||
it('should format small numbers as-is', () => {
|
||||
expect(formatTokenCount(100)).toBe('100');
|
||||
expect(formatTokenCount(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should format thousands with k suffix', () => {
|
||||
expect(formatTokenCount(1000)).toBe('1.0k');
|
||||
expect(formatTokenCount(1500)).toBe('1.5k');
|
||||
expect(formatTokenCount(10000)).toBe('10.0k');
|
||||
});
|
||||
|
||||
it('should handle zero', () => {
|
||||
expect(formatTokenCount(0)).toBe('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
306
packages/cognitive-context/src/tokens.ts
Normal file
306
packages/cognitive-context/src/tokens.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Token counting and budget management module
|
||||
*
|
||||
* Uses js-tiktoken with cl100k_base encoding (GPT-4 compatible)
|
||||
* for accurate token counting.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { getEncoding, Tiktoken } from 'js-tiktoken';
|
||||
import type { TokenBudget, TokenReport } from './types.js';
|
||||
|
||||
// ============================================
|
||||
// Encoder Instance (cached for performance)
|
||||
// ============================================
|
||||
|
||||
let encoderInstance: Tiktoken | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the tiktoken encoder instance
|
||||
* Uses cl100k_base encoding (GPT-4/ChatGPT compatible)
|
||||
*/
|
||||
function getEncoder(): Tiktoken {
|
||||
if (!encoderInstance) {
|
||||
encoderInstance = getEncoding('cl100k_base');
|
||||
}
|
||||
return encoderInstance;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Core Token Counting
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Count the number of tokens in a text string
|
||||
*
|
||||
* @param text - The text to count tokens for
|
||||
* @returns The number of tokens
|
||||
*
|
||||
* @example
|
||||
* const count = countTokens('Hello, world!');
|
||||
* console.log(count); // 4
|
||||
*/
|
||||
export function countTokens(text: string): number {
|
||||
if (!text || text.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const encoder = getEncoder();
|
||||
const tokens = encoder.encode(text);
|
||||
return tokens.length;
|
||||
} catch (error) {
|
||||
// Handle encoding errors gracefully - estimate based on characters
|
||||
// Rough estimate: ~4 characters per token for English text
|
||||
console.warn('Token encoding failed, using character-based estimate:', error);
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tokens in a file
|
||||
*
|
||||
* @param filePath - Path to the file to count tokens for
|
||||
* @param budget - Optional budget to compare against (defaults to Infinity)
|
||||
* @returns TokenReport with file statistics
|
||||
*
|
||||
* @example
|
||||
* const report = await countFileTokens('./README.md', 1000);
|
||||
* console.log(report.overBudget); // false if under 1000 tokens
|
||||
*/
|
||||
export async function countFileTokens(
|
||||
filePath: string,
|
||||
budget: number = Infinity
|
||||
): Promise<TokenReport> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
const tokens = countTokens(content);
|
||||
|
||||
return {
|
||||
file: filePath,
|
||||
tokens,
|
||||
budget,
|
||||
overBudget: tokens > budget,
|
||||
percentage: budget === Infinity ? 0 : Math.round((tokens / budget) * 100),
|
||||
};
|
||||
} catch (error) {
|
||||
// If file can't be read, return a report indicating the error
|
||||
// with 0 tokens (can't count what we can't read)
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.warn(`Failed to read file ${filePath}: ${message}`);
|
||||
|
||||
return {
|
||||
file: filePath,
|
||||
tokens: 0,
|
||||
budget,
|
||||
overBudget: false,
|
||||
percentage: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Budget Validation
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Validate token reports against a budget configuration
|
||||
*
|
||||
* @param reports - Array of TokenReport objects to validate
|
||||
* @param budget - TokenBudget configuration with limits
|
||||
* @returns Validation result with valid flag and any violations
|
||||
*
|
||||
* @example
|
||||
* const reports = [
|
||||
* { file: 'SUMMARY.md', tokens: 250, budget: 300, overBudget: false, percentage: 83 },
|
||||
* { file: 'wisdom/guide.md', tokens: 2000, budget: 1500, overBudget: true, percentage: 133 },
|
||||
* ];
|
||||
* const result = validateBudget(reports, budget);
|
||||
* console.log(result.valid); // false
|
||||
* console.log(result.violations); // [{ file: 'wisdom/guide.md', ... }]
|
||||
*/
|
||||
export function validateBudget(
|
||||
reports: TokenReport[],
|
||||
budget: TokenBudget
|
||||
): { valid: boolean; violations: TokenReport[] } {
|
||||
const violations: TokenReport[] = [];
|
||||
|
||||
// Check individual file budgets
|
||||
for (const report of reports) {
|
||||
if (report.overBudget) {
|
||||
violations.push(report);
|
||||
}
|
||||
}
|
||||
|
||||
// Check total budget
|
||||
const totalTokens = reports.reduce((sum, r) => sum + r.tokens, 0);
|
||||
if (totalTokens > budget.total) {
|
||||
// Add a synthetic report for total budget violation
|
||||
violations.push({
|
||||
file: '[TOTAL]',
|
||||
tokens: totalTokens,
|
||||
budget: budget.total,
|
||||
overBudget: true,
|
||||
percentage: Math.round((totalTokens / budget.total) * 100),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
valid: violations.length === 0,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Text Truncation
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Truncate text to fit within a token limit
|
||||
*
|
||||
* Preserves complete words/sentences when possible by truncating
|
||||
* at sentence or word boundaries.
|
||||
*
|
||||
* @param text - The text to truncate
|
||||
* @param maxTokens - Maximum number of tokens allowed
|
||||
* @returns Truncated text that fits within the limit
|
||||
*
|
||||
* @example
|
||||
* const longText = 'This is a very long text...';
|
||||
* const truncated = truncateToTokenLimit(longText, 10);
|
||||
* console.log(countTokens(truncated) <= 10); // true
|
||||
*/
|
||||
export function truncateToTokenLimit(text: string, maxTokens: number): string {
|
||||
if (!text || maxTokens <= 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const currentTokens = countTokens(text);
|
||||
if (currentTokens <= maxTokens) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Binary search for the right length
|
||||
// Start with an estimate based on the ratio
|
||||
const ratio = maxTokens / currentTokens;
|
||||
let low = 0;
|
||||
let high = text.length;
|
||||
let result = '';
|
||||
|
||||
// Initial estimate
|
||||
let mid = Math.floor(text.length * ratio * 0.9); // Start slightly under
|
||||
|
||||
// Refine with binary search
|
||||
while (low < high) {
|
||||
mid = Math.floor((low + high + 1) / 2);
|
||||
const substring = text.slice(0, mid);
|
||||
const tokens = countTokens(substring);
|
||||
|
||||
if (tokens <= maxTokens) {
|
||||
result = substring;
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find a clean break point (sentence or word boundary)
|
||||
const cleanBreak = findCleanBreakPoint(result);
|
||||
|
||||
return cleanBreak || result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a clean break point in text (sentence or word boundary)
|
||||
*/
|
||||
function findCleanBreakPoint(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
// Try to find the last sentence boundary
|
||||
const sentenceMatch = text.match(/^(.+[.!?])\s*[^.!?]*$/);
|
||||
if (sentenceMatch && sentenceMatch[1].length > text.length * 0.7) {
|
||||
return sentenceMatch[1];
|
||||
}
|
||||
|
||||
// Fall back to last word boundary
|
||||
const wordMatch = text.match(/^(.+)\s+\S*$/);
|
||||
if (wordMatch && wordMatch[1].length > text.length * 0.8) {
|
||||
return wordMatch[1];
|
||||
}
|
||||
|
||||
// If no good break point, return as-is
|
||||
return text;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Create a TokenReport for a given file path and content
|
||||
*
|
||||
* @param file - File path
|
||||
* @param content - File content
|
||||
* @param budget - Token budget for this file
|
||||
* @returns TokenReport
|
||||
*/
|
||||
export function createTokenReport(
|
||||
file: string,
|
||||
content: string,
|
||||
budget: number
|
||||
): TokenReport {
|
||||
const tokens = countTokens(content);
|
||||
return {
|
||||
file,
|
||||
tokens,
|
||||
budget,
|
||||
overBudget: tokens > budget,
|
||||
percentage: budget > 0 ? Math.round((tokens / budget) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get budget for a specific file type based on TokenBudget config
|
||||
*
|
||||
* @param filePath - The file path
|
||||
* @param budget - TokenBudget configuration
|
||||
* @returns The applicable budget for this file
|
||||
*/
|
||||
export function getBudgetForFile(filePath: string, budget: TokenBudget): number {
|
||||
const lowerPath = filePath.toLowerCase();
|
||||
|
||||
if (lowerPath.includes('summary')) {
|
||||
return budget.summary;
|
||||
}
|
||||
|
||||
if (lowerPath.includes('capabilities')) {
|
||||
return budget.capabilities;
|
||||
}
|
||||
|
||||
if (lowerPath.includes('wisdom')) {
|
||||
return budget.wisdomPerFile;
|
||||
}
|
||||
|
||||
// Default to total budget if no specific category matches
|
||||
return budget.total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a token count for display
|
||||
*
|
||||
* @param tokens - Number of tokens
|
||||
* @returns Formatted string (e.g., "1.2k" for 1200)
|
||||
*/
|
||||
export function formatTokenCount(tokens: number): string {
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return tokens.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the encoder instance (useful for testing)
|
||||
*/
|
||||
export function resetEncoder(): void {
|
||||
encoderInstance = null;
|
||||
}
|
||||
244
packages/cognitive-context/src/types.ts
Normal file
244
packages/cognitive-context/src/types.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Core type definitions for the Cognitive Context System
|
||||
*
|
||||
* This file defines all shared types used across the system.
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// Entity Extraction Types
|
||||
// ============================================
|
||||
|
||||
export type EntityType =
|
||||
| 'component'
|
||||
| 'hook'
|
||||
| 'utility'
|
||||
| 'type'
|
||||
| 'interface'
|
||||
| 'schema'
|
||||
| 'endpoint'
|
||||
| 'module';
|
||||
|
||||
export interface ExtractedExport {
|
||||
name: string;
|
||||
kind: 'function' | 'class' | 'const' | 'type' | 'interface' | 'enum';
|
||||
isDefault: boolean;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface ExtractedImport {
|
||||
source: string;
|
||||
specifiers: string[];
|
||||
isRelative: boolean;
|
||||
}
|
||||
|
||||
export interface ExtractedEntity {
|
||||
/** Entity name (primary export) */
|
||||
name: string;
|
||||
/** File path relative to project root */
|
||||
path: string;
|
||||
/** Entity type classification */
|
||||
type: EntityType;
|
||||
/** All exports from the file */
|
||||
exports: ExtractedExport[];
|
||||
/** All imports (for dependency tracking) */
|
||||
imports: ExtractedImport[];
|
||||
/** Props interface (for components) */
|
||||
props?: PropDefinition[];
|
||||
/** Last modified timestamp */
|
||||
modifiedAt: Date;
|
||||
/** File hash for change detection */
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface PropDefinition {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
defaultValue?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Knowledge Graph Types
|
||||
// ============================================
|
||||
|
||||
export interface KnowledgeGraph {
|
||||
meta: KnowledgeMeta;
|
||||
entities: Record<string, ExtractedEntity>;
|
||||
relationships: EntityRelationship[];
|
||||
}
|
||||
|
||||
export interface KnowledgeMeta {
|
||||
generatedAt: string;
|
||||
fromCommit: string;
|
||||
totalEntities: number;
|
||||
projectRoot: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface EntityRelationship {
|
||||
from: string;
|
||||
to: string;
|
||||
type: 'imports' | 'extends' | 'implements' | 'uses';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Validation Types
|
||||
// ============================================
|
||||
|
||||
export interface ValidationResult {
|
||||
/** Overall completeness score (0-100) */
|
||||
score: number;
|
||||
/** Entities in code but not documented */
|
||||
missing: string[];
|
||||
/** Entities documented but not in code */
|
||||
stale: string[];
|
||||
/** Warnings (non-blocking) */
|
||||
warnings: ValidationWarning[];
|
||||
/** Suggestions for improvement */
|
||||
suggestions: string[];
|
||||
/** Validation timestamp */
|
||||
validatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ValidationWarning {
|
||||
type: 'low-confidence' | 'outdated' | 'incomplete';
|
||||
entity: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Drift Detection Types
|
||||
// ============================================
|
||||
|
||||
export type StalenessLevel = 'fresh' | 'stale' | 'critical';
|
||||
|
||||
export interface DriftReport {
|
||||
/** Number of files changed since last sync */
|
||||
filesChanged: number;
|
||||
/** New entities detected */
|
||||
entitiesAdded: string[];
|
||||
/** Entities removed from code */
|
||||
entitiesRemoved: string[];
|
||||
/** Entities modified */
|
||||
entitiesModified: string[];
|
||||
/** Last sync timestamp */
|
||||
lastSync: Date;
|
||||
/** Staleness classification */
|
||||
staleness: StalenessLevel;
|
||||
/** Recommended action */
|
||||
recommendation: 'none' | 'sync' | 'regenerate';
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Token Budgeting Types
|
||||
// ============================================
|
||||
|
||||
export interface TokenBudget {
|
||||
/** Maximum tokens for SUMMARY.md */
|
||||
summary: number;
|
||||
/** Maximum tokens for capabilities.yaml */
|
||||
capabilities: number;
|
||||
/** Maximum tokens per wisdom file */
|
||||
wisdomPerFile: number;
|
||||
/** Total budget across all files */
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TokenReport {
|
||||
file: string;
|
||||
tokens: number;
|
||||
budget: number;
|
||||
overBudget: boolean;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Configuration Types
|
||||
// ============================================
|
||||
|
||||
export interface CognitiveConfig {
|
||||
/** Project root directory */
|
||||
projectRoot: string;
|
||||
/** Source directories to scan */
|
||||
sourceDirs: string[];
|
||||
/** File patterns to include */
|
||||
includePatterns: string[];
|
||||
/** Patterns to exclude */
|
||||
excludePatterns: string[];
|
||||
/** Token budgets */
|
||||
tokenBudget: TokenBudget;
|
||||
/** Target tools to sync */
|
||||
tools: ToolConfig[];
|
||||
/** Watch mode settings */
|
||||
watch: WatchConfig;
|
||||
}
|
||||
|
||||
export interface ToolConfig {
|
||||
name: 'cursor' | 'claude' | 'continue' | 'aider' | 'copilot' | 'windsurf';
|
||||
enabled: boolean;
|
||||
outputPath: string;
|
||||
}
|
||||
|
||||
export interface WatchConfig {
|
||||
enabled: boolean;
|
||||
debounceMs: number;
|
||||
ignorePaths: string[];
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Sync Types
|
||||
// ============================================
|
||||
|
||||
export interface SyncResult {
|
||||
tool: string;
|
||||
filesWritten: string[];
|
||||
filesDeleted: string[];
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SyncReport {
|
||||
timestamp: Date;
|
||||
results: SyncResult[];
|
||||
totalFilesWritten: number;
|
||||
totalFilesDeleted: number;
|
||||
allSuccessful: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CLI Types
|
||||
// ============================================
|
||||
|
||||
export interface CLIContext {
|
||||
config: CognitiveConfig;
|
||||
projectRoot: string;
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
export type CLICommand =
|
||||
| 'init'
|
||||
| 'extract'
|
||||
| 'validate'
|
||||
| 'sync'
|
||||
| 'watch'
|
||||
| 'drift'
|
||||
| 'status';
|
||||
|
||||
// ============================================
|
||||
// Event Types (for watcher)
|
||||
// ============================================
|
||||
|
||||
export type FileEventType = 'add' | 'change' | 'unlink';
|
||||
|
||||
export interface FileEvent {
|
||||
type: FileEventType;
|
||||
path: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface WatcherCallbacks {
|
||||
onFileChange?: (event: FileEvent) => void;
|
||||
onEntityChange?: (entity: ExtractedEntity, changeType: 'added' | 'modified' | 'removed') => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
597
packages/cognitive-context/src/validator.ts
Normal file
597
packages/cognitive-context/src/validator.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* Completeness Validator
|
||||
*
|
||||
* Validates that documented capabilities match extracted code entities.
|
||||
* Ensures the knowledge graph stays in sync with actual code.
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import type {
|
||||
KnowledgeGraph,
|
||||
ValidationResult,
|
||||
ValidationWarning,
|
||||
EntityType,
|
||||
} from './types.js';
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Represents a single capability entry in the YAML file
|
||||
*/
|
||||
export interface CapabilityEntry {
|
||||
/** The file path or package reference */
|
||||
path: string;
|
||||
/** Original key in the YAML (e.g., 'line', 'bar') */
|
||||
key: string;
|
||||
/** Full qualified path (e.g., 'ui_components.charts.line') */
|
||||
qualifiedPath: string;
|
||||
/** Inferred entity type */
|
||||
type?: EntityType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed capabilities.yaml structure
|
||||
*/
|
||||
export interface DocumentedCapabilities {
|
||||
/** Flattened list of all capability entries */
|
||||
entries: CapabilityEntry[];
|
||||
/** Raw YAML structure for reference */
|
||||
raw: Record<string, unknown>;
|
||||
/** Top-level categories found */
|
||||
categories: string[];
|
||||
/** File path this was loaded from */
|
||||
sourcePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for validation
|
||||
*/
|
||||
export interface ValidateOptions {
|
||||
/**
|
||||
* Minimum confidence score to consider an entity match (0-100)
|
||||
* @default 80
|
||||
*/
|
||||
minConfidence?: number;
|
||||
|
||||
/**
|
||||
* Whether to include warnings for partial matches
|
||||
* @default true
|
||||
*/
|
||||
includePartialMatches?: boolean;
|
||||
|
||||
/**
|
||||
* Categories to skip during validation
|
||||
* @default ['framework', 'packages_available', 'patterns']
|
||||
*/
|
||||
skipCategories?: string[];
|
||||
|
||||
/**
|
||||
* Whether to be strict about entity type matching
|
||||
* @default false
|
||||
*/
|
||||
strictTypeMatching?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ValidateOptions> = {
|
||||
minConfidence: 80,
|
||||
includePartialMatches: true,
|
||||
skipCategories: ['framework', 'packages_available', 'patterns', 'data_models'],
|
||||
strictTypeMatching: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Categories that typically contain code entities (for future use in auto-categorization)
|
||||
*/
|
||||
const _CODE_CATEGORIES = ['ui_components', 'hooks', 'utilities', 'schemas', 'api'];
|
||||
void _CODE_CATEGORIES; // Reserved for future use
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Flatten a nested YAML object into capability entries
|
||||
*/
|
||||
function flattenCapabilities(
|
||||
obj: unknown,
|
||||
parentPath: string = '',
|
||||
entries: CapabilityEntry[] = []
|
||||
): CapabilityEntry[] {
|
||||
if (obj === null || obj === undefined) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
// Leaf node - this is a file path
|
||||
const key = parentPath.split('.').pop() || parentPath;
|
||||
entries.push({
|
||||
path: obj,
|
||||
key,
|
||||
qualifiedPath: parentPath,
|
||||
type: inferEntityType(parentPath, obj),
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (typeof obj === 'object' && !Array.isArray(obj)) {
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
// Skip keys that start with underscore (metadata like _docs)
|
||||
if (key.startsWith('_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newPath = parentPath ? `${parentPath}.${key}` : key;
|
||||
flattenCapabilities(value, newPath, entries);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
// Arrays in capabilities are typically lists of items, not file paths
|
||||
// Skip them for validation purposes
|
||||
return entries;
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer entity type from the qualified path and file path
|
||||
*/
|
||||
function inferEntityType(qualifiedPath: string, filePath: string): EntityType | undefined {
|
||||
const pathLower = qualifiedPath.toLowerCase();
|
||||
const filePathLower = filePath.toLowerCase();
|
||||
|
||||
if (pathLower.includes('component') || pathLower.includes('ui_components')) {
|
||||
return 'component';
|
||||
}
|
||||
if (pathLower.includes('hook') || filePathLower.includes('use')) {
|
||||
return 'hook';
|
||||
}
|
||||
if (pathLower.includes('schema') || filePathLower.includes('schema')) {
|
||||
return 'schema';
|
||||
}
|
||||
if (pathLower.includes('api') || pathLower.includes('endpoint')) {
|
||||
return 'endpoint';
|
||||
}
|
||||
if (pathLower.includes('util') || pathLower.includes('helper')) {
|
||||
return 'utility';
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract entity name from a file path
|
||||
*/
|
||||
function extractEntityName(filePath: string): string {
|
||||
// Handle paths like 'liquid-render/components/data-table.tsx'
|
||||
const parts = filePath.split('/');
|
||||
const fileName = parts[parts.length - 1];
|
||||
|
||||
// Remove extension
|
||||
const nameWithoutExt = fileName.replace(/\.(tsx?|jsx?|js)$/, '');
|
||||
|
||||
// Convert kebab-case to PascalCase for components
|
||||
return nameWithoutExt
|
||||
.split('-')
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an entity name for comparison
|
||||
*/
|
||||
function normalizeEntityName(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
// Remove common prefixes/suffixes
|
||||
.replace(/^(use|get|set|is|has)/, '')
|
||||
.replace(/(component|hook|util|helper|schema)$/, '')
|
||||
// Convert any case to lowercase
|
||||
.replace(/([A-Z])/g, '-$1')
|
||||
.replace(/^-/, '')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity between two strings (0-100)
|
||||
*/
|
||||
function calculateSimilarity(str1: string, str2: string): number {
|
||||
const s1 = normalizeEntityName(str1);
|
||||
const s2 = normalizeEntityName(str2);
|
||||
|
||||
if (s1 === s2) return 100;
|
||||
|
||||
// Check if one contains the other
|
||||
if (s1.includes(s2) || s2.includes(s1)) {
|
||||
const longer = s1.length > s2.length ? s1 : s2;
|
||||
const shorter = s1.length > s2.length ? s2 : s1;
|
||||
return Math.round((shorter.length / longer.length) * 100);
|
||||
}
|
||||
|
||||
// Levenshtein distance-based similarity
|
||||
const matrix: number[][] = [];
|
||||
const n = s1.length;
|
||||
const m = s2.length;
|
||||
|
||||
if (n === 0) return m === 0 ? 100 : 0;
|
||||
if (m === 0) return 0;
|
||||
|
||||
for (let i = 0; i <= n; i++) {
|
||||
matrix[i] = [i];
|
||||
}
|
||||
for (let j = 0; j <= m; j++) {
|
||||
matrix[0][j] = j;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= n; i++) {
|
||||
for (let j = 1; j <= m; j++) {
|
||||
const cost = s1[i - 1] === s2[j - 1] ? 0 : 1;
|
||||
matrix[i][j] = Math.min(
|
||||
matrix[i - 1][j] + 1,
|
||||
matrix[i][j - 1] + 1,
|
||||
matrix[i - 1][j - 1] + cost
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const maxLen = Math.max(n, m);
|
||||
const distance = matrix[n][m];
|
||||
return Math.round(((maxLen - distance) / maxLen) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching documented entity for a code entity
|
||||
*/
|
||||
function findBestMatch(
|
||||
entityName: string,
|
||||
documentedEntries: CapabilityEntry[],
|
||||
minConfidence: number
|
||||
): { entry: CapabilityEntry; confidence: number } | null {
|
||||
let bestMatch: { entry: CapabilityEntry; confidence: number } | null = null;
|
||||
|
||||
for (const entry of documentedEntries) {
|
||||
const documentedName = extractEntityName(entry.path);
|
||||
const similarity = calculateSimilarity(entityName, documentedName);
|
||||
|
||||
// Also check the key itself
|
||||
const keySimilarity = calculateSimilarity(entityName, entry.key);
|
||||
const maxSimilarity = Math.max(similarity, keySimilarity);
|
||||
|
||||
if (maxSimilarity >= minConfidence) {
|
||||
if (!bestMatch || maxSimilarity > bestMatch.confidence) {
|
||||
bestMatch = { entry, confidence: maxSimilarity };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the completeness score
|
||||
*/
|
||||
function calculateScore(
|
||||
totalEntities: number,
|
||||
missingCount: number,
|
||||
staleCount: number
|
||||
): number {
|
||||
if (totalEntities === 0) {
|
||||
return 100; // Nothing to validate
|
||||
}
|
||||
|
||||
const issueCount = missingCount + staleCount;
|
||||
const score = 100 - (issueCount / totalEntities) * 100;
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(score)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate suggestions based on validation findings
|
||||
*/
|
||||
function generateSuggestions(
|
||||
missing: string[],
|
||||
stale: string[],
|
||||
warnings: ValidationWarning[]
|
||||
): string[] {
|
||||
const suggestions: string[] = [];
|
||||
|
||||
if (missing.length > 0) {
|
||||
if (missing.length <= 3) {
|
||||
suggestions.push(
|
||||
`Add documentation for: ${missing.join(', ')}`
|
||||
);
|
||||
} else {
|
||||
suggestions.push(
|
||||
`Add documentation for ${missing.length} undocumented entities (run with --verbose for full list)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (stale.length > 0) {
|
||||
if (stale.length <= 3) {
|
||||
suggestions.push(
|
||||
`Remove or update stale entries: ${stale.join(', ')}`
|
||||
);
|
||||
} else {
|
||||
suggestions.push(
|
||||
`Clean up ${stale.length} stale documentation entries`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const lowConfidenceWarnings = warnings.filter(w => w.type === 'low-confidence');
|
||||
if (lowConfidenceWarnings.length > 0) {
|
||||
suggestions.push(
|
||||
'Review entities with low-confidence matches - names may have drifted'
|
||||
);
|
||||
}
|
||||
|
||||
const incompleteWarnings = warnings.filter(w => w.type === 'incomplete');
|
||||
if (incompleteWarnings.length > 0) {
|
||||
suggestions.push(
|
||||
'Some documented entries reference files that could not be verified'
|
||||
);
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
suggestions.push('Documentation is complete and up-to-date!');
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a category should be validated (contains code references)
|
||||
*/
|
||||
function shouldValidateCategory(category: string, skipCategories: string[]): boolean {
|
||||
return !skipCategories.includes(category);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// YAML Parsing
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Parse a capabilities YAML file into structured format
|
||||
*/
|
||||
export function parseCapabilitiesYaml(
|
||||
content: string,
|
||||
sourcePath?: string
|
||||
): DocumentedCapabilities {
|
||||
const raw = parseYaml(content) as Record<string, unknown>;
|
||||
const categories = Object.keys(raw).filter(k => !k.startsWith('_'));
|
||||
const entries = flattenCapabilities(raw);
|
||||
|
||||
return {
|
||||
entries,
|
||||
raw,
|
||||
categories,
|
||||
sourcePath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and parse a capabilities.yaml file
|
||||
*/
|
||||
export async function loadCapabilitiesFile(
|
||||
filePath: string
|
||||
): Promise<DocumentedCapabilities> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
return parseCapabilitiesYaml(content, filePath);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// File doesn't exist - return empty capabilities
|
||||
return {
|
||||
entries: [],
|
||||
raw: {},
|
||||
categories: [],
|
||||
sourcePath: filePath,
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`Failed to load capabilities file: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Validation Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Validate that documented capabilities match the knowledge graph
|
||||
*
|
||||
* @param knowledgeGraph - Extracted entities from code
|
||||
* @param documentedCapabilities - Parsed capabilities.yaml content
|
||||
* @param options - Validation options
|
||||
* @returns Validation result with score, missing/stale entities, and suggestions
|
||||
*/
|
||||
export function validateCompleteness(
|
||||
knowledgeGraph: KnowledgeGraph,
|
||||
documentedCapabilities: DocumentedCapabilities,
|
||||
options: ValidateOptions = {}
|
||||
): ValidationResult {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const missing: string[] = [];
|
||||
const stale: string[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
|
||||
// Get entities from the knowledge graph
|
||||
const codeEntities = Object.values(knowledgeGraph.entities);
|
||||
|
||||
// Filter documented entries to only include code-relevant categories
|
||||
const codeRelatedEntries = documentedCapabilities.entries.filter(entry => {
|
||||
const category = entry.qualifiedPath.split('.')[0];
|
||||
return shouldValidateCategory(category, opts.skipCategories);
|
||||
});
|
||||
|
||||
// Track which documented entries have been matched
|
||||
const matchedDocumentedEntries = new Set<string>();
|
||||
const matchedCodeEntities = new Set<string>();
|
||||
|
||||
// Check each code entity against documentation
|
||||
for (const entity of codeEntities) {
|
||||
const match = findBestMatch(entity.name, codeRelatedEntries, opts.minConfidence);
|
||||
|
||||
if (match) {
|
||||
matchedDocumentedEntries.add(match.entry.qualifiedPath);
|
||||
matchedCodeEntities.add(entity.name);
|
||||
|
||||
// Add warning for low-confidence matches
|
||||
if (match.confidence < 100 && match.confidence >= opts.minConfidence) {
|
||||
warnings.push({
|
||||
type: 'low-confidence',
|
||||
entity: entity.name,
|
||||
message: `Matched to '${match.entry.qualifiedPath}' with ${match.confidence}% confidence`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check type mismatch if strict matching is enabled
|
||||
if (opts.strictTypeMatching && match.entry.type && match.entry.type !== entity.type) {
|
||||
warnings.push({
|
||||
type: 'incomplete',
|
||||
entity: entity.name,
|
||||
message: `Type mismatch: documented as '${match.entry.type}', code is '${entity.type}'`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Entity in code but not documented
|
||||
missing.push(entity.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Find stale documentation (documented but not in code)
|
||||
for (const entry of codeRelatedEntries) {
|
||||
if (!matchedDocumentedEntries.has(entry.qualifiedPath)) {
|
||||
// Check if the path contains a package reference (not a file path)
|
||||
const isPackageRef = entry.path.startsWith('@') || !entry.path.includes('/');
|
||||
|
||||
if (!isPackageRef) {
|
||||
stale.push(entry.qualifiedPath);
|
||||
warnings.push({
|
||||
type: 'outdated',
|
||||
entity: entry.qualifiedPath,
|
||||
message: `Documented entry '${entry.path}' not found in extracted code`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total entities for scoring
|
||||
// Consider both documented and code entities
|
||||
const totalEntities = new Set([
|
||||
...codeEntities.map(e => e.name),
|
||||
...codeRelatedEntries.map(e => e.qualifiedPath),
|
||||
]).size;
|
||||
|
||||
const score = calculateScore(totalEntities, missing.length, stale.length);
|
||||
const suggestions = generateSuggestions(missing, stale, warnings);
|
||||
|
||||
return {
|
||||
score,
|
||||
missing,
|
||||
stale,
|
||||
warnings,
|
||||
suggestions,
|
||||
validatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a capabilities file against a knowledge graph
|
||||
*
|
||||
* Convenience function that loads the capabilities file and runs validation.
|
||||
*
|
||||
* @param filePath - Path to capabilities.yaml file
|
||||
* @param knowledgeGraph - Extracted knowledge graph
|
||||
* @param options - Validation options
|
||||
* @returns Validation result
|
||||
*/
|
||||
export async function validateCapabilitiesFile(
|
||||
filePath: string,
|
||||
knowledgeGraph: KnowledgeGraph,
|
||||
options: ValidateOptions = {}
|
||||
): Promise<ValidationResult> {
|
||||
const capabilities = await loadCapabilitiesFile(filePath);
|
||||
return validateCompleteness(knowledgeGraph, capabilities, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty validation result (for cases with no data)
|
||||
*/
|
||||
export function createEmptyValidationResult(): ValidationResult {
|
||||
return {
|
||||
score: 100,
|
||||
missing: [],
|
||||
stale: [],
|
||||
warnings: [],
|
||||
suggestions: ['No entities to validate'],
|
||||
validatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a validation result indicates issues
|
||||
*/
|
||||
export function hasValidationIssues(result: ValidationResult): boolean {
|
||||
return result.missing.length > 0 || result.stale.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format validation result as a human-readable string
|
||||
*/
|
||||
export function formatValidationResult(result: ValidationResult): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`Completeness Score: ${result.score}%`);
|
||||
lines.push('');
|
||||
|
||||
if (result.missing.length > 0) {
|
||||
lines.push(`Missing from documentation (${result.missing.length}):`);
|
||||
for (const entity of result.missing.slice(0, 10)) {
|
||||
lines.push(` - ${entity}`);
|
||||
}
|
||||
if (result.missing.length > 10) {
|
||||
lines.push(` ... and ${result.missing.length - 10} more`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (result.stale.length > 0) {
|
||||
lines.push(`Stale documentation (${result.stale.length}):`);
|
||||
for (const entry of result.stale.slice(0, 10)) {
|
||||
lines.push(` - ${entry}`);
|
||||
}
|
||||
if (result.stale.length > 10) {
|
||||
lines.push(` ... and ${result.stale.length - 10} more`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (result.warnings.length > 0) {
|
||||
lines.push(`Warnings (${result.warnings.length}):`);
|
||||
for (const warning of result.warnings.slice(0, 5)) {
|
||||
lines.push(` [${warning.type}] ${warning.entity}: ${warning.message}`);
|
||||
}
|
||||
if (result.warnings.length > 5) {
|
||||
lines.push(` ... and ${result.warnings.length - 5} more`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('Suggestions:');
|
||||
for (const suggestion of result.suggestions) {
|
||||
lines.push(` - ${suggestion}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
286
packages/cognitive-context/src/watcher.test.ts
Normal file
286
packages/cognitive-context/src/watcher.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Watcher Module Tests
|
||||
*
|
||||
* Tests for the file watcher functionality.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { FileEvent, WatchConfig, WatcherCallbacks } from './types.js';
|
||||
import {
|
||||
createWatcher,
|
||||
createDefaultWatchConfig,
|
||||
mergeIgnorePaths,
|
||||
} from './watcher.js';
|
||||
|
||||
// ============================================
|
||||
// Mock chokidar
|
||||
// ============================================
|
||||
|
||||
// Create mock event emitter functionality
|
||||
type EventHandler = (path: string) => void;
|
||||
type ErrorHandler = (error: Error) => void;
|
||||
|
||||
interface MockWatcher {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
_handlers: {
|
||||
add: EventHandler[];
|
||||
change: EventHandler[];
|
||||
unlink: EventHandler[];
|
||||
error: ErrorHandler[];
|
||||
};
|
||||
_emit: (event: string, data: string | Error) => void;
|
||||
}
|
||||
|
||||
let mockWatcherInstance: MockWatcher;
|
||||
|
||||
vi.mock('chokidar', () => ({
|
||||
default: {
|
||||
watch: vi.fn(() => {
|
||||
mockWatcherInstance = {
|
||||
_handlers: {
|
||||
add: [],
|
||||
change: [],
|
||||
unlink: [],
|
||||
error: [],
|
||||
},
|
||||
on: vi.fn(function (this: MockWatcher, event: string, handler: EventHandler | ErrorHandler) {
|
||||
if (event in this._handlers) {
|
||||
(this._handlers as Record<string, (EventHandler | ErrorHandler)[]>)[event].push(handler);
|
||||
}
|
||||
return this;
|
||||
}),
|
||||
close: vi.fn(() => Promise.resolve()),
|
||||
_emit: function (event: string, data: string | Error) {
|
||||
const handlers = (this._handlers as Record<string, (EventHandler | ErrorHandler)[]>)[event];
|
||||
if (handlers) {
|
||||
handlers.forEach((h) => {
|
||||
if (event === 'error') {
|
||||
(h as ErrorHandler)(data as Error);
|
||||
} else {
|
||||
(h as EventHandler)(data as string);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
return mockWatcherInstance;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================
|
||||
// Test Utilities
|
||||
// ============================================
|
||||
|
||||
function createMockCallbacks(): WatcherCallbacks & {
|
||||
events: FileEvent[];
|
||||
errors: Error[];
|
||||
} {
|
||||
const events: FileEvent[] = [];
|
||||
const errors: Error[] = [];
|
||||
|
||||
return {
|
||||
events,
|
||||
errors,
|
||||
onFileChange: vi.fn((event: FileEvent) => {
|
||||
events.push(event);
|
||||
}),
|
||||
onError: vi.fn((error: Error) => {
|
||||
errors.push(error);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createTestConfig(overrides: Partial<WatchConfig> = {}): WatchConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
debounceMs: 50, // Use short debounce for tests
|
||||
ignorePaths: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests
|
||||
// ============================================
|
||||
|
||||
describe('createWatcher', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create a watcher that starts and stops correctly', () => {
|
||||
const callbacks = createMockCallbacks();
|
||||
const config = createTestConfig();
|
||||
const watcher = createWatcher(['./src'], config, callbacks);
|
||||
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
|
||||
watcher.start();
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
|
||||
watcher.stop();
|
||||
expect(watcher.isRunning()).toBe(false);
|
||||
expect(mockWatcherInstance.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should debounce file change events', async () => {
|
||||
const callbacks = createMockCallbacks();
|
||||
const config = createTestConfig({ debounceMs: 100 });
|
||||
const watcher = createWatcher(['./src'], config, callbacks);
|
||||
|
||||
watcher.start();
|
||||
|
||||
// Emit multiple events in quick succession
|
||||
mockWatcherInstance._emit('change', '/src/file1.ts');
|
||||
mockWatcherInstance._emit('change', '/src/file2.ts');
|
||||
mockWatcherInstance._emit('change', '/src/file1.ts'); // Same file again
|
||||
|
||||
// Events should not be processed yet
|
||||
expect(callbacks.events).toHaveLength(0);
|
||||
|
||||
// Advance time past debounce threshold
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
// Now events should be processed (file1 should only appear once due to deduplication)
|
||||
expect(callbacks.events).toHaveLength(2);
|
||||
expect(callbacks.events.map((e) => e.path)).toContain('/src/file1.ts');
|
||||
expect(callbacks.events.map((e) => e.path)).toContain('/src/file2.ts');
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('should emit correct event types for add, change, and unlink', async () => {
|
||||
const callbacks = createMockCallbacks();
|
||||
const config = createTestConfig({ debounceMs: 50 });
|
||||
const watcher = createWatcher(['./src'], config, callbacks);
|
||||
|
||||
watcher.start();
|
||||
|
||||
mockWatcherInstance._emit('add', '/src/new-file.ts');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
mockWatcherInstance._emit('change', '/src/existing-file.ts');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
mockWatcherInstance._emit('unlink', '/src/deleted-file.ts');
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
expect(callbacks.events).toHaveLength(3);
|
||||
expect(callbacks.events[0]).toMatchObject({
|
||||
type: 'add',
|
||||
path: '/src/new-file.ts',
|
||||
});
|
||||
expect(callbacks.events[1]).toMatchObject({
|
||||
type: 'change',
|
||||
path: '/src/existing-file.ts',
|
||||
});
|
||||
expect(callbacks.events[2]).toMatchObject({
|
||||
type: 'unlink',
|
||||
path: '/src/deleted-file.ts',
|
||||
});
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('should call onError callback when chokidar emits an error', () => {
|
||||
const callbacks = createMockCallbacks();
|
||||
const config = createTestConfig();
|
||||
const watcher = createWatcher(['./src'], config, callbacks);
|
||||
|
||||
watcher.start();
|
||||
|
||||
const testError = new Error('Permission denied');
|
||||
mockWatcherInstance._emit('error', testError);
|
||||
|
||||
expect(callbacks.errors).toHaveLength(1);
|
||||
expect(callbacks.errors[0].message).toBe('Permission denied');
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('should not start again if already running', () => {
|
||||
const callbacks = createMockCallbacks();
|
||||
const config = createTestConfig();
|
||||
const watcher = createWatcher(['./src'], config, callbacks);
|
||||
|
||||
watcher.start();
|
||||
watcher.start(); // Should be a no-op
|
||||
|
||||
expect(watcher.isRunning()).toBe(true);
|
||||
|
||||
watcher.stop();
|
||||
});
|
||||
|
||||
it('should flush pending events when stopped', () => {
|
||||
const callbacks = createMockCallbacks();
|
||||
const config = createTestConfig({ debounceMs: 1000 });
|
||||
const watcher = createWatcher(['./src'], config, callbacks);
|
||||
|
||||
watcher.start();
|
||||
|
||||
mockWatcherInstance._emit('change', '/src/file.ts');
|
||||
|
||||
// Events not flushed yet due to long debounce
|
||||
expect(callbacks.events).toHaveLength(0);
|
||||
|
||||
// Stop should flush immediately
|
||||
watcher.stop();
|
||||
|
||||
expect(callbacks.events).toHaveLength(1);
|
||||
expect(callbacks.events[0].path).toBe('/src/file.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDefaultWatchConfig', () => {
|
||||
it('should create config with default values', () => {
|
||||
const config = createDefaultWatchConfig();
|
||||
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.debounceMs).toBe(500);
|
||||
expect(config.ignorePaths).toContain('node_modules');
|
||||
expect(config.ignorePaths).toContain('.git');
|
||||
});
|
||||
|
||||
it('should allow overriding default values', () => {
|
||||
const config = createDefaultWatchConfig({
|
||||
enabled: false,
|
||||
debounceMs: 1000,
|
||||
});
|
||||
|
||||
expect(config.enabled).toBe(false);
|
||||
expect(config.debounceMs).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeIgnorePaths', () => {
|
||||
it('should merge user paths with defaults', () => {
|
||||
const merged = mergeIgnorePaths(['.cache', 'tmp']);
|
||||
|
||||
expect(merged).toContain('node_modules');
|
||||
expect(merged).toContain('.git');
|
||||
expect(merged).toContain('.cache');
|
||||
expect(merged).toContain('tmp');
|
||||
});
|
||||
|
||||
it('should deduplicate paths', () => {
|
||||
const merged = mergeIgnorePaths(['node_modules', 'custom']);
|
||||
|
||||
const nodeModulesCount = merged.filter((p) => p === 'node_modules').length;
|
||||
expect(nodeModulesCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should work with empty array', () => {
|
||||
const merged = mergeIgnorePaths([]);
|
||||
|
||||
expect(merged).toContain('node_modules');
|
||||
expect(merged).toContain('.git');
|
||||
expect(merged.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
238
packages/cognitive-context/src/watcher.ts
Normal file
238
packages/cognitive-context/src/watcher.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* File Watcher Module
|
||||
*
|
||||
* Watches source directories for file changes and emits debounced events.
|
||||
* Uses chokidar for cross-platform file system watching.
|
||||
*/
|
||||
|
||||
import chokidar from 'chokidar';
|
||||
import type { FSWatcher } from 'chokidar';
|
||||
import type { FileEventType, WatchConfig, WatcherCallbacks } from './types.js';
|
||||
|
||||
// ============================================
|
||||
// Constants
|
||||
// ============================================
|
||||
|
||||
const DEFAULT_DEBOUNCE_MS = 500;
|
||||
const DEFAULT_IGNORE_PATHS = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'coverage',
|
||||
];
|
||||
|
||||
// ============================================
|
||||
// Watcher Interface
|
||||
// ============================================
|
||||
|
||||
export interface Watcher {
|
||||
/** Start watching the configured directories */
|
||||
start(): void;
|
||||
/** Stop watching and clean up resources */
|
||||
stop(): void;
|
||||
/** Check if the watcher is currently running */
|
||||
isRunning(): boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Internal Types
|
||||
// ============================================
|
||||
|
||||
interface PendingEvent {
|
||||
type: FileEventType;
|
||||
path: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Factory Function
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Creates a file watcher that monitors source directories for changes.
|
||||
*
|
||||
* @param sourceDirs - Array of directories to watch
|
||||
* @param config - Watch configuration options
|
||||
* @param callbacks - Event callbacks
|
||||
* @returns A Watcher instance
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const watcher = createWatcher(
|
||||
* ['./src', './lib'],
|
||||
* { enabled: true, debounceMs: 300, ignorePaths: [] },
|
||||
* {
|
||||
* onFileChange: (event) => console.log('File changed:', event.path),
|
||||
* onError: (error) => console.error('Watch error:', error),
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* watcher.start();
|
||||
* // ... later
|
||||
* watcher.stop();
|
||||
* ```
|
||||
*/
|
||||
export function createWatcher(
|
||||
sourceDirs: string[],
|
||||
config: WatchConfig,
|
||||
callbacks: WatcherCallbacks
|
||||
): Watcher {
|
||||
let fsWatcher: FSWatcher | null = null;
|
||||
let running = false;
|
||||
let pendingEvents: Map<string, PendingEvent> = new Map();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const debounceMs = config.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
||||
const ignorePaths = [
|
||||
...DEFAULT_IGNORE_PATHS,
|
||||
...(config.ignorePaths ?? []),
|
||||
];
|
||||
|
||||
/**
|
||||
* Flushes all pending events, calling the onFileChange callback for each
|
||||
*/
|
||||
function flushEvents(): void {
|
||||
if (pendingEvents.size === 0) return;
|
||||
|
||||
const events = Array.from(pendingEvents.values());
|
||||
pendingEvents.clear();
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
callbacks.onFileChange?.(event);
|
||||
} catch (error) {
|
||||
callbacks.onError?.(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a debounced flush of events
|
||||
*/
|
||||
function scheduleFlush(): void {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null;
|
||||
flushEvents();
|
||||
}, debounceMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a raw file system event from chokidar
|
||||
*/
|
||||
function handleEvent(type: FileEventType, path: string): void {
|
||||
const event: PendingEvent = {
|
||||
type,
|
||||
path,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
// Use path as key - later events for same path override earlier ones
|
||||
pendingEvents.set(path, event);
|
||||
scheduleFlush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles errors from chokidar
|
||||
*/
|
||||
function handleError(error: Error): void {
|
||||
callbacks.onError?.(error);
|
||||
}
|
||||
|
||||
return {
|
||||
start(): void {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build ignore patterns from configured paths
|
||||
const ignorePatterns = ignorePaths.map((p) => `**/${p}/**`);
|
||||
|
||||
fsWatcher = chokidar.watch(sourceDirs, {
|
||||
ignored: ignorePatterns,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 100,
|
||||
pollInterval: 50,
|
||||
},
|
||||
});
|
||||
|
||||
fsWatcher
|
||||
.on('add', (path) => handleEvent('add', path))
|
||||
.on('change', (path) => handleEvent('change', path))
|
||||
.on('unlink', (path) => handleEvent('unlink', path))
|
||||
.on('error', handleError);
|
||||
|
||||
running = true;
|
||||
} catch (error) {
|
||||
callbacks.onError?.(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending debounce timer
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = null;
|
||||
}
|
||||
|
||||
// Flush any remaining events immediately
|
||||
flushEvents();
|
||||
|
||||
// Close the watcher
|
||||
if (fsWatcher) {
|
||||
fsWatcher.close().catch((error) => {
|
||||
callbacks.onError?.(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
});
|
||||
fsWatcher = null;
|
||||
}
|
||||
|
||||
running = false;
|
||||
},
|
||||
|
||||
isRunning(): boolean {
|
||||
return running;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Utility Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Creates a WatchConfig with sensible defaults
|
||||
*/
|
||||
export function createDefaultWatchConfig(
|
||||
overrides: Partial<WatchConfig> = {}
|
||||
): WatchConfig {
|
||||
return {
|
||||
enabled: overrides.enabled ?? true,
|
||||
debounceMs: overrides.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
||||
ignorePaths: overrides.ignorePaths ?? [...DEFAULT_IGNORE_PATHS],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges user-provided ignore paths with defaults
|
||||
*/
|
||||
export function mergeIgnorePaths(userPaths: string[] = []): string[] {
|
||||
const combined = new Set([...DEFAULT_IGNORE_PATHS, ...userPaths]);
|
||||
return Array.from(combined);
|
||||
}
|
||||
25
packages/cognitive-context/tsconfig.json
Normal file
25
packages/cognitive-context/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user