feat: whyrating - initial project from turbostarter boilerplate

This commit is contained in:
Alejandro Gutiérrez
2026-02-04 01:54:52 +01:00
commit 5cdc07cd39
1618 changed files with 338230 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
import { run } from '../dist/cli.js';
run();

View 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"
}

View 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';

View 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';

View File

@@ -0,0 +1,8 @@
/**
* Tool-specific adapters for Cognitive Context
*
* @packageDocumentation
*/
export * from './cursor.js';
export * from './claude.js';

View 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

View 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 };
}

View 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);
});
});

View 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;
}
}

View 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',
};
}

View 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');
});
});

View 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;
}

View File

@@ -0,0 +1,7 @@
/**
* Git hooks for Cognitive Context
*
* @packageDocumentation
*/
export * from './pre-commit.js';

View 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);
}

View 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';

File diff suppressed because it is too large Load Diff

View 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');
});
});
});

View 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;
}

View 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;
}

View 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');
}

View 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);
});
});

View 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);
}

View 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"]
}