diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index fbf4017c..7770c54e 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -27,7 +27,9 @@ import { generateStructureSection, generateSummarySection, } from './skill/skillSectionGenerators.js'; +import { calculateStatistics, generateStatisticsSection } from './skill/skillStatistics.js'; import { generateSkillMd } from './skill/skillStyle.js'; +import { detectTechStack, generateTechStackMd } from './skill/skillTechStack.js'; import { generateProjectName, generateSkillDescription, validateSkillName } from './skill/skillUtils.js'; const calculateMarkdownDelimiter = (files: ReadonlyArray): string => { @@ -374,6 +376,7 @@ export interface SkillReferences { summary: string; structure: string; files: string; + techStack?: string; } /** @@ -385,6 +388,9 @@ export interface SkillReferencesResult { projectName: string; skillDescription: string; totalFiles: number; + totalLines: number; + statisticsSection: string; + hasTechStack: boolean; } /** @@ -396,7 +402,7 @@ export interface SkillOutputResult { } /** - * Generates skill reference files (summary, structure, files, git-diffs, git-logs). + * Generates skill reference files (summary, structure, files, tech-stack). * This is the first step - call this, calculate metrics, then call generateSkillMdFromReferences. */ export const generateSkillReferences = async ( @@ -443,11 +449,20 @@ export const generateSkillReferences = async ( ); const renderContext = createRenderContext(outputGeneratorContext); + // Calculate statistics + const statistics = calculateStatistics(sortedProcessedFiles, renderContext.fileLineCounts); + const statisticsSection = generateStatisticsSection(statistics); + + // Detect tech stack + const techStack = detectTechStack(sortedProcessedFiles); + const techStackMd = techStack ? generateTechStackMd(techStack) : undefined; + // Generate each section separately const references: SkillReferences = { summary: generateSummarySection(renderContext), structure: generateStructureSection(renderContext), files: generateFilesSection(renderContext), + techStack: techStackMd, }; return { @@ -456,6 +471,9 @@ export const generateSkillReferences = async ( projectName, skillDescription, totalFiles: sortedProcessedFiles.length, + totalLines: statistics.totalLines, + statisticsSection, + hasTechStack: techStack !== null, }; }; @@ -472,7 +490,10 @@ export const generateSkillMdFromReferences = ( skillDescription: referencesResult.skillDescription, projectName: referencesResult.projectName, totalFiles: referencesResult.totalFiles, + totalLines: referencesResult.totalLines, totalTokens, + statisticsSection: referencesResult.statisticsSection, + hasTechStack: referencesResult.hasTechStack, }); return { diff --git a/src/core/output/skill/skillStatistics.ts b/src/core/output/skill/skillStatistics.ts new file mode 100644 index 00000000..a9966e84 --- /dev/null +++ b/src/core/output/skill/skillStatistics.ts @@ -0,0 +1,196 @@ +import path from 'node:path'; +import type { ProcessedFile } from '../../file/fileTypes.js'; + +interface FileTypeStats { + extension: string; + language: string; + fileCount: number; + lineCount: number; +} + +interface StatisticsInfo { + totalFiles: number; + totalLines: number; + byFileType: FileTypeStats[]; + largestFiles: Array<{ path: string; lines: number }>; +} + +// Map extensions to language names +const EXTENSION_TO_LANGUAGE: Record = { + // JavaScript/TypeScript + '.js': 'JavaScript', + '.jsx': 'JavaScript (JSX)', + '.ts': 'TypeScript', + '.tsx': 'TypeScript (TSX)', + '.mjs': 'JavaScript (ESM)', + '.cjs': 'JavaScript (CJS)', + + // Web + '.html': 'HTML', + '.htm': 'HTML', + '.css': 'CSS', + '.scss': 'SCSS', + '.sass': 'Sass', + '.less': 'Less', + '.vue': 'Vue', + '.svelte': 'Svelte', + + // Data/Config + '.json': 'JSON', + '.yaml': 'YAML', + '.yml': 'YAML', + '.toml': 'TOML', + '.xml': 'XML', + '.ini': 'INI', + '.env': 'Environment', + + // Documentation + '.md': 'Markdown', + '.mdx': 'MDX', + '.rst': 'reStructuredText', + '.txt': 'Text', + + // Backend + '.py': 'Python', + '.rb': 'Ruby', + '.php': 'PHP', + '.java': 'Java', + '.kt': 'Kotlin', + '.kts': 'Kotlin Script', + '.scala': 'Scala', + '.go': 'Go', + '.rs': 'Rust', + '.c': 'C', + '.cpp': 'C++', + '.cc': 'C++', + '.h': 'C/C++ Header', + '.hpp': 'C++ Header', + '.cs': 'C#', + '.swift': 'Swift', + '.m': 'Objective-C', + '.mm': 'Objective-C++', + + // Shell/Scripts + '.sh': 'Shell', + '.bash': 'Bash', + '.zsh': 'Zsh', + '.fish': 'Fish', + '.ps1': 'PowerShell', + '.bat': 'Batch', + '.cmd': 'Batch', + + // Other + '.sql': 'SQL', + '.graphql': 'GraphQL', + '.gql': 'GraphQL', + '.proto': 'Protocol Buffers', + '.dockerfile': 'Dockerfile', + '.lua': 'Lua', + '.r': 'R', + '.ex': 'Elixir', + '.exs': 'Elixir Script', + '.erl': 'Erlang', + '.clj': 'Clojure', + '.hs': 'Haskell', + '.ml': 'OCaml', + '.nim': 'Nim', + '.zig': 'Zig', + '.dart': 'Dart', + '.v': 'V', + '.sol': 'Solidity', +}; + +/** + * Gets language name from file extension. + */ +const getLanguageFromExtension = (ext: string): string => { + return EXTENSION_TO_LANGUAGE[ext.toLowerCase()] || ext.slice(1).toUpperCase() || 'Unknown'; +}; + +/** + * Calculates statistics from processed files. + */ +export const calculateStatistics = ( + processedFiles: ProcessedFile[], + fileLineCounts: Record, +): StatisticsInfo => { + const statsByExt: Record = {}; + let totalLines = 0; + + // Calculate stats by extension + for (const file of processedFiles) { + const ext = path.extname(file.path).toLowerCase() || '(no ext)'; + const lines = fileLineCounts[file.path] || file.content.split('\n').length; + + if (!statsByExt[ext]) { + statsByExt[ext] = { fileCount: 0, lineCount: 0 }; + } + statsByExt[ext].fileCount++; + statsByExt[ext].lineCount += lines; + totalLines += lines; + } + + // Convert to array and sort by file count + const byFileType: FileTypeStats[] = Object.entries(statsByExt) + .map(([ext, stats]) => ({ + extension: ext, + language: ext === '(no ext)' ? 'No Extension' : getLanguageFromExtension(ext), + fileCount: stats.fileCount, + lineCount: stats.lineCount, + })) + .sort((a, b) => b.fileCount - a.fileCount); + + // Get largest files (top 5) + const largestFiles = processedFiles + .map((file) => ({ + path: file.path, + lines: fileLineCounts[file.path] || file.content.split('\n').length, + })) + .sort((a, b) => b.lines - a.lines) + .slice(0, 5); + + return { + totalFiles: processedFiles.length, + totalLines, + byFileType, + largestFiles, + }; +}; + +/** + * Generates statistics markdown table for SKILL.md. + */ +export const generateStatisticsSection = (stats: StatisticsInfo): string => { + const lines: string[] = ['## Statistics', '']; + + // Summary line + lines.push(`${stats.totalFiles} files | ${stats.totalLines.toLocaleString()} lines`); + lines.push(''); + + // File type table (top 10) + lines.push('| Language | Files | Lines |'); + lines.push('|----------|------:|------:|'); + + const topTypes = stats.byFileType.slice(0, 10); + for (const type of topTypes) { + lines.push(`| ${type.language} | ${type.fileCount} | ${type.lineCount.toLocaleString()} |`); + } + + if (stats.byFileType.length > 10) { + const otherFiles = stats.byFileType.slice(10).reduce((sum, t) => sum + t.fileCount, 0); + const otherLines = stats.byFileType.slice(10).reduce((sum, t) => sum + t.lineCount, 0); + lines.push(`| Other | ${otherFiles} | ${otherLines.toLocaleString()} |`); + } + + lines.push(''); + + // Largest files + if (stats.largestFiles.length > 0) { + lines.push('**Largest files:**'); + for (const file of stats.largestFiles) { + lines.push(`- \`${file.path}\` (${file.lines.toLocaleString()} lines)`); + } + } + + return lines.join('\n'); +}; diff --git a/src/core/output/skill/skillStyle.ts b/src/core/output/skill/skillStyle.ts index e5b10405..da713c81 100644 --- a/src/core/output/skill/skillStyle.ts +++ b/src/core/output/skill/skillStyle.ts @@ -5,7 +5,10 @@ export interface SkillRenderContext { skillDescription: string; projectName: string; totalFiles: number; + totalLines: number; totalTokens: number; + statisticsSection: string; + hasTechStack: boolean; } /** @@ -20,7 +23,7 @@ description: {{{skillDescription}}} # {{{projectName}}} Codebase Reference -{{{totalFiles}}} files | {{{totalTokens}}} tokens +{{{totalFiles}}} files | {{{totalLines}}} lines | {{{totalTokens}}} tokens ## Overview @@ -34,15 +37,20 @@ Use this skill when you need to: | File | Contents | |------|----------| -| \`references/structure.md\` | Directory tree with line counts per file | +| \`references/project-structure.md\` | Directory tree with line counts per file | | \`references/files.md\` | All file contents (header: \`## File: \`) | +{{#if hasTechStack}} +| \`references/tech-stack.md\` | Languages, frameworks, and dependencies | +{{/if}} | \`references/summary.md\` | Purpose and format explanation | +{{{statisticsSection}}} + ## How to Use ### 1. Find file locations -Check \`structure.md\` for the directory tree: +Check \`project-structure.md\` for the directory tree: \`\`\` src/ @@ -70,22 +78,25 @@ function calculateTotal ## Common Use Cases **Understand a feature:** -1. Search \`structure.md\` for related file names +1. Search \`project-structure.md\` for related file names 2. Read the main implementation file in \`files.md\` 3. Search for imports/references to trace dependencies **Debug an error:** 1. Grep the error message or class name in \`files.md\` -2. Check line counts in \`structure.md\` to find large files +2. Check line counts in \`project-structure.md\` to find large files **Find all usages:** 1. Grep function or variable name in \`files.md\` ## Tips -- Use line counts in \`structure.md\` to estimate file complexity +- Use line counts in \`project-structure.md\` to estimate file complexity - Search \`## File:\` pattern to jump between files - Check \`summary.md\` for excluded files and format details +{{#if hasTechStack}} +- Check \`tech-stack.md\` for languages, frameworks, and dependencies +{{/if}} `; }; diff --git a/src/core/output/skill/skillTechStack.ts b/src/core/output/skill/skillTechStack.ts new file mode 100644 index 00000000..955b3d02 --- /dev/null +++ b/src/core/output/skill/skillTechStack.ts @@ -0,0 +1,405 @@ +import type { ProcessedFile } from '../../file/fileTypes.js'; + +interface DependencyInfo { + name: string; + version?: string; + isDev?: boolean; +} + +interface TechStackInfo { + languages: string[]; + frameworks: string[]; + dependencies: DependencyInfo[]; + devDependencies: DependencyInfo[]; + packageManager?: string; +} + +// Dependency file patterns and their parsers +const DEPENDENCY_FILES: Record Partial }> = { + 'package.json': { language: 'Node.js', parser: parsePackageJson }, + 'requirements.txt': { language: 'Python', parser: parseRequirementsTxt }, + 'pyproject.toml': { language: 'Python', parser: parsePyprojectToml }, + Pipfile: { language: 'Python', parser: parsePipfile }, + 'go.mod': { language: 'Go', parser: parseGoMod }, + 'Cargo.toml': { language: 'Rust', parser: parseCargoToml }, + 'composer.json': { language: 'PHP', parser: parseComposerJson }, + Gemfile: { language: 'Ruby', parser: parseGemfile }, + 'pom.xml': { language: 'Java', parser: parsePomXml }, + 'build.gradle': { language: 'Java/Kotlin', parser: parseBuildGradle }, + 'build.gradle.kts': { language: 'Kotlin', parser: parseBuildGradle }, +}; + +function parsePackageJson(content: string): Partial { + try { + const pkg = JSON.parse(content); + const dependencies: DependencyInfo[] = []; + const devDependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Parse dependencies + if (pkg.dependencies) { + for (const [name, version] of Object.entries(pkg.dependencies)) { + dependencies.push({ name, version: String(version) }); + + // Detect frameworks + if (name === 'react' || name === 'react-dom') frameworks.push('React'); + if (name === 'vue') frameworks.push('Vue'); + if (name === 'next') frameworks.push('Next.js'); + if (name === 'nuxt') frameworks.push('Nuxt'); + if (name === '@angular/core') frameworks.push('Angular'); + if (name === 'express') frameworks.push('Express'); + if (name === 'fastify') frameworks.push('Fastify'); + if (name === 'hono') frameworks.push('Hono'); + if (name === 'svelte') frameworks.push('Svelte'); + } + } + + // Parse devDependencies + if (pkg.devDependencies) { + for (const [name, version] of Object.entries(pkg.devDependencies)) { + devDependencies.push({ name, version: String(version), isDev: true }); + + // Detect TypeScript + if (name === 'typescript') frameworks.push('TypeScript'); + } + } + + // Detect package manager + let packageManager: string | undefined; + if (pkg.packageManager) { + const pm = String(pkg.packageManager); + if (pm.startsWith('pnpm')) packageManager = 'pnpm'; + else if (pm.startsWith('yarn')) packageManager = 'yarn'; + else if (pm.startsWith('npm')) packageManager = 'npm'; + else if (pm.startsWith('bun')) packageManager = 'bun'; + } + + return { + dependencies, + devDependencies, + frameworks: [...new Set(frameworks)], + packageManager, + }; + } catch { + return {}; + } +} + +function parseRequirementsTxt(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('-')) continue; + + // Parse package==version or package>=version format + const match = trimmed.match(/^([a-zA-Z0-9_-]+)([=<>!~]+)?(.+)?$/); + if (match) { + const name = match[1]; + const version = match[3]; + dependencies.push({ name, version }); + + // Detect frameworks + if (name.toLowerCase() === 'django') frameworks.push('Django'); + if (name.toLowerCase() === 'flask') frameworks.push('Flask'); + if (name.toLowerCase() === 'fastapi') frameworks.push('FastAPI'); + if (name.toLowerCase() === 'pytorch' || name.toLowerCase() === 'torch') frameworks.push('PyTorch'); + if (name.toLowerCase() === 'tensorflow') frameworks.push('TensorFlow'); + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parsePyprojectToml(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Simple TOML parsing for dependencies + const depsMatch = content.match(/\[project\.dependencies\]([\s\S]*?)(?=\[|$)/); + if (depsMatch) { + const depsSection = depsMatch[1]; + const depLines = depsSection.match(/"([^"]+)"/g); + if (depLines) { + for (const dep of depLines) { + const name = dep + .replace(/"/g, '') + .split(/[=<>!~]/)[0] + .trim(); + if (name) { + dependencies.push({ name }); + if (name.toLowerCase() === 'django') frameworks.push('Django'); + if (name.toLowerCase() === 'flask') frameworks.push('Flask'); + if (name.toLowerCase() === 'fastapi') frameworks.push('FastAPI'); + } + } + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parsePipfile(content: string): Partial { + const dependencies: DependencyInfo[] = []; + + // Simple parsing for [packages] section + const packagesMatch = content.match(/\[packages\]([\s\S]*?)(?=\[|$)/); + if (packagesMatch) { + const lines = packagesMatch[1].split('\n'); + for (const line of lines) { + const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/); + if (match) { + dependencies.push({ name: match[1] }); + } + } + } + + return { dependencies }; +} + +function parseGoMod(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Parse require block + const requireMatch = content.match(/require\s*\(([\s\S]*?)\)/); + if (requireMatch) { + const lines = requireMatch[1].split('\n'); + for (const line of lines) { + const match = line.trim().match(/^([^\s]+)\s+([^\s]+)/); + if (match) { + const name = match[1]; + const version = match[2]; + dependencies.push({ name, version }); + + if (name.includes('gin-gonic/gin')) frameworks.push('Gin'); + if (name.includes('labstack/echo')) frameworks.push('Echo'); + if (name.includes('gofiber/fiber')) frameworks.push('Fiber'); + } + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parseCargoToml(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Parse [dependencies] section + const depsMatch = content.match(/\[dependencies\]([\s\S]*?)(?=\[|$)/); + if (depsMatch) { + const lines = depsMatch[1].split('\n'); + for (const line of lines) { + const match = line.match(/^([a-zA-Z0-9_-]+)\s*=/); + if (match) { + const name = match[1]; + dependencies.push({ name }); + + if (name === 'actix-web') frameworks.push('Actix'); + if (name === 'axum') frameworks.push('Axum'); + if (name === 'rocket') frameworks.push('Rocket'); + if (name === 'tokio') frameworks.push('Tokio'); + } + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parseComposerJson(content: string): Partial { + try { + const composer = JSON.parse(content); + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + if (composer.require) { + for (const [name, version] of Object.entries(composer.require)) { + if (name !== 'php') { + dependencies.push({ name, version: String(version) }); + + if (name.startsWith('laravel/')) frameworks.push('Laravel'); + if (name.startsWith('symfony/')) frameworks.push('Symfony'); + } + } + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; + } catch { + return {}; + } +} + +function parseGemfile(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + const gemMatches = content.matchAll(/gem\s+['"]([^'"]+)['"]/g); + for (const match of gemMatches) { + const name = match[1]; + dependencies.push({ name }); + + if (name === 'rails') frameworks.push('Rails'); + if (name === 'sinatra') frameworks.push('Sinatra'); + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parsePomXml(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Simple XML parsing for dependencies + const depMatches = content.matchAll(/[\s\S]*?([^<]+)<\/artifactId>[\s\S]*?<\/dependency>/g); + for (const match of depMatches) { + const name = match[1]; + dependencies.push({ name }); + + if (name.includes('spring')) frameworks.push('Spring'); + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +function parseBuildGradle(content: string): Partial { + const dependencies: DependencyInfo[] = []; + const frameworks: string[] = []; + + // Parse implementation/compile dependencies + const depMatches = content.matchAll(/(?:implementation|compile)\s*['"(]([^'"()]+)['"]/g); + for (const match of depMatches) { + const dep = match[1]; + const parts = dep.split(':'); + const name = parts.length >= 2 ? parts[1] : dep; + dependencies.push({ name }); + + if (dep.includes('spring')) frameworks.push('Spring'); + if (dep.includes('ktor')) frameworks.push('Ktor'); + } + + return { dependencies, frameworks: [...new Set(frameworks)] }; +} + +/** + * Detects tech stack from processed files. + * Only checks root-level dependency files. + */ +export const detectTechStack = (processedFiles: ProcessedFile[]): TechStackInfo | null => { + const result: TechStackInfo = { + languages: [], + frameworks: [], + dependencies: [], + devDependencies: [], + }; + + let foundAny = false; + + for (const file of processedFiles) { + // Only check root-level files (no directory separator in path) + const fileName = file.path.split('/').pop() || file.path; + if (file.path !== fileName && !file.path.startsWith('./')) { + // Skip files in subdirectories + const dirDepth = file.path.split('/').length - 1; + if (dirDepth > 0) continue; + } + + const config = DEPENDENCY_FILES[fileName]; + if (config) { + foundAny = true; + result.languages.push(config.language); + + const parsed = config.parser(file.content); + if (parsed.dependencies) { + result.dependencies.push(...parsed.dependencies); + } + if (parsed.devDependencies) { + result.devDependencies.push(...parsed.devDependencies); + } + if (parsed.frameworks) { + result.frameworks.push(...parsed.frameworks); + } + if (parsed.packageManager) { + result.packageManager = parsed.packageManager; + } + } + } + + if (!foundAny) { + return null; + } + + // Deduplicate + result.languages = [...new Set(result.languages)]; + result.frameworks = [...new Set(result.frameworks)]; + + return result; +}; + +/** + * Generates tech-stack.md content from detected tech stack. + */ +export const generateTechStackMd = (techStack: TechStackInfo): string => { + const lines: string[] = ['# Tech Stack', '']; + + // Languages + if (techStack.languages.length > 0) { + lines.push('## Languages'); + lines.push(''); + for (const lang of techStack.languages) { + lines.push(`- ${lang}`); + } + lines.push(''); + } + + // Frameworks + if (techStack.frameworks.length > 0) { + lines.push('## Frameworks'); + lines.push(''); + for (const fw of techStack.frameworks) { + lines.push(`- ${fw}`); + } + lines.push(''); + } + + // Package Manager + if (techStack.packageManager) { + lines.push('## Package Manager'); + lines.push(''); + lines.push(`- ${techStack.packageManager}`); + lines.push(''); + } + + // Dependencies (limit to top 20 for readability) + if (techStack.dependencies.length > 0) { + lines.push('## Dependencies'); + lines.push(''); + const deps = techStack.dependencies.slice(0, 20); + for (const dep of deps) { + const version = dep.version ? ` (${dep.version})` : ''; + lines.push(`- ${dep.name}${version}`); + } + if (techStack.dependencies.length > 20) { + lines.push(`- ... and ${techStack.dependencies.length - 20} more`); + } + lines.push(''); + } + + // Dev Dependencies (limit to top 10) + if (techStack.devDependencies.length > 0) { + lines.push('## Dev Dependencies'); + lines.push(''); + const devDeps = techStack.devDependencies.slice(0, 10); + for (const dep of devDeps) { + const version = dep.version ? ` (${dep.version})` : ''; + lines.push(`- ${dep.name}${version}`); + } + if (techStack.devDependencies.length > 10) { + lines.push(`- ... and ${techStack.devDependencies.length - 10} more`); + } + lines.push(''); + } + + return lines.join('\n').trim(); +}; diff --git a/src/core/packager/writeSkillOutput.ts b/src/core/packager/writeSkillOutput.ts index a01df9d8..2de687d4 100644 --- a/src/core/packager/writeSkillOutput.ts +++ b/src/core/packager/writeSkillOutput.ts @@ -10,8 +10,9 @@ import type { SkillOutputResult } from '../output/outputGenerate.js'; * ├── SKILL.md * └── references/ * ├── summary.md - * ├── structure.md - * └── files.md + * ├── project-structure.md + * ├── files.md + * └── tech-stack.md (if available) */ export const writeSkillOutput = async ( output: SkillOutputResult, @@ -33,9 +34,14 @@ export const writeSkillOutput = async ( // Write reference files await deps.writeFile(path.join(referencesDir, 'summary.md'), output.references.summary, 'utf-8'); - await deps.writeFile(path.join(referencesDir, 'structure.md'), output.references.structure, 'utf-8'); + await deps.writeFile(path.join(referencesDir, 'project-structure.md'), output.references.structure, 'utf-8'); await deps.writeFile(path.join(referencesDir, 'files.md'), output.references.files, 'utf-8'); + // Write tech-stack.md if available + if (output.references.techStack) { + await deps.writeFile(path.join(referencesDir, 'tech-stack.md'), output.references.techStack, 'utf-8'); + } + return skillDir; } catch (error) { const nodeError = error as NodeJS.ErrnoException; diff --git a/tests/core/output/skill/skillStatistics.test.ts b/tests/core/output/skill/skillStatistics.test.ts new file mode 100644 index 00000000..e5baa7bd --- /dev/null +++ b/tests/core/output/skill/skillStatistics.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from 'vitest'; +import type { ProcessedFile } from '../../../../src/core/file/fileTypes.js'; +import { calculateStatistics, generateStatisticsSection } from '../../../../src/core/output/skill/skillStatistics.js'; + +describe('skillStatistics', () => { + describe('calculateStatistics', () => { + test('should calculate statistics by file type', () => { + const files: ProcessedFile[] = [ + { path: 'src/index.ts', content: 'line1\nline2\nline3' }, + { path: 'src/utils.ts', content: 'line1\nline2' }, + { path: 'src/styles.css', content: 'line1' }, + { path: 'README.md', content: 'line1\nline2\nline3\nline4' }, + ]; + + const lineCounts = { + 'src/index.ts': 3, + 'src/utils.ts': 2, + 'src/styles.css': 1, + 'README.md': 4, + }; + + const result = calculateStatistics(files, lineCounts); + + expect(result.totalFiles).toBe(4); + expect(result.totalLines).toBe(10); + expect(result.byFileType.length).toBe(3); + + const tsStats = result.byFileType.find((s) => s.extension === '.ts'); + expect(tsStats?.fileCount).toBe(2); + expect(tsStats?.lineCount).toBe(5); + expect(tsStats?.language).toBe('TypeScript'); + + const cssStats = result.byFileType.find((s) => s.extension === '.css'); + expect(cssStats?.fileCount).toBe(1); + expect(cssStats?.lineCount).toBe(1); + expect(cssStats?.language).toBe('CSS'); + + const mdStats = result.byFileType.find((s) => s.extension === '.md'); + expect(mdStats?.fileCount).toBe(1); + expect(mdStats?.lineCount).toBe(4); + expect(mdStats?.language).toBe('Markdown'); + }); + + test('should return largest files sorted by line count', () => { + const files: ProcessedFile[] = [ + { path: 'small.ts', content: 'a' }, + { path: 'large.ts', content: 'a\nb\nc\nd\ne' }, + { path: 'medium.ts', content: 'a\nb\nc' }, + ]; + + const lineCounts = { + 'small.ts': 1, + 'large.ts': 5, + 'medium.ts': 3, + }; + + const result = calculateStatistics(files, lineCounts); + + expect(result.largestFiles[0].path).toBe('large.ts'); + expect(result.largestFiles[0].lines).toBe(5); + expect(result.largestFiles[1].path).toBe('medium.ts'); + expect(result.largestFiles[2].path).toBe('small.ts'); + }); + + test('should limit largest files to 5', () => { + const files: ProcessedFile[] = Array.from({ length: 10 }, (_, i) => ({ + path: `file${i}.ts`, + content: 'a'.repeat(i + 1), + })); + + const lineCounts = Object.fromEntries(files.map((f, i) => [f.path, i + 1])); + + const result = calculateStatistics(files, lineCounts); + + expect(result.largestFiles.length).toBe(5); + }); + + test('should sort file types by file count', () => { + const files: ProcessedFile[] = [ + { path: 'a.ts', content: 'a' }, + { path: 'b.ts', content: 'a' }, + { path: 'c.ts', content: 'a' }, + { path: 'x.js', content: 'a' }, + { path: 'y.css', content: 'a' }, + { path: 'z.css', content: 'a' }, + ]; + + const lineCounts = Object.fromEntries(files.map((f) => [f.path, 1])); + + const result = calculateStatistics(files, lineCounts); + + expect(result.byFileType[0].extension).toBe('.ts'); + expect(result.byFileType[0].fileCount).toBe(3); + expect(result.byFileType[1].extension).toBe('.css'); + expect(result.byFileType[1].fileCount).toBe(2); + }); + + test('should handle files without extension', () => { + const files: ProcessedFile[] = [ + { path: 'Dockerfile', content: 'FROM node' }, + { path: 'Makefile', content: 'all:' }, + ]; + + const lineCounts = { + Dockerfile: 1, + Makefile: 1, + }; + + const result = calculateStatistics(files, lineCounts); + + const noExtStats = result.byFileType.find((s) => s.extension === '(no ext)'); + expect(noExtStats?.fileCount).toBe(2); + expect(noExtStats?.language).toBe('No Extension'); + }); + }); + + describe('generateStatisticsSection', () => { + test('should generate statistics markdown', () => { + const stats = { + totalFiles: 10, + totalLines: 500, + byFileType: [ + { extension: '.ts', language: 'TypeScript', fileCount: 5, lineCount: 300 }, + { extension: '.js', language: 'JavaScript', fileCount: 3, lineCount: 150 }, + { extension: '.css', language: 'CSS', fileCount: 2, lineCount: 50 }, + ], + largestFiles: [ + { path: 'src/main.ts', lines: 200 }, + { path: 'src/utils.ts', lines: 100 }, + ], + }; + + const result = generateStatisticsSection(stats); + + expect(result).toContain('## Statistics'); + expect(result).toContain('10 files | 500 lines'); + expect(result).toContain('| Language | Files | Lines |'); + expect(result).toContain('| TypeScript | 5 | 300 |'); + expect(result).toContain('| JavaScript | 3 | 150 |'); + expect(result).toContain('| CSS | 2 | 50 |'); + expect(result).toContain('**Largest files:**'); + expect(result).toContain('`src/main.ts` (200 lines)'); + expect(result).toContain('`src/utils.ts` (100 lines)'); + }); + + test('should limit file types to 10 and show "Other" row', () => { + const stats = { + totalFiles: 50, + totalLines: 1000, + byFileType: Array.from({ length: 15 }, (_, i) => ({ + extension: `.ext${i}`, + language: `Language${i}`, + fileCount: 5 - Math.floor(i / 3), + lineCount: 100 - i * 5, + })), + largestFiles: [], + }; + + const result = generateStatisticsSection(stats); + + expect(result).toContain('| Other |'); + expect(result).not.toContain('Language14'); + }); + + test('should format large numbers with locale string', () => { + const stats = { + totalFiles: 100, + totalLines: 10000, + byFileType: [{ extension: '.ts', language: 'TypeScript', fileCount: 100, lineCount: 10000 }], + largestFiles: [{ path: 'big.ts', lines: 5000 }], + }; + + const result = generateStatisticsSection(stats); + + expect(result).toContain('10,000'); + expect(result).toContain('5,000'); + }); + }); +}); diff --git a/tests/core/output/skill/skillStyle.test.ts b/tests/core/output/skill/skillStyle.test.ts index 00896772..daf33262 100644 --- a/tests/core/output/skill/skillStyle.test.ts +++ b/tests/core/output/skill/skillStyle.test.ts @@ -36,20 +36,34 @@ describe('skillStyle', () => { test('should reference multiple files', () => { const template = getSkillTemplate(); expect(template).toContain('references/summary.md'); - expect(template).toContain('references/structure.md'); + expect(template).toContain('references/project-structure.md'); expect(template).toContain('references/files.md'); }); }); describe('generateSkillMd', () => { + const createTestContext = (overrides = {}) => ({ + skillName: 'test-skill', + skillDescription: 'Test description', + projectName: 'Test Project', + totalFiles: 1, + totalLines: 100, + totalTokens: 100, + statisticsSection: '## Statistics\n\n1 files | 100 lines', + hasTechStack: false, + ...overrides, + }); + test('should generate SKILL.md with all fields', () => { - const context = { + const context = createTestContext({ skillName: 'my-project-skill', skillDescription: 'Reference codebase for My Project.', projectName: 'My Project', totalFiles: 42, + totalLines: 1000, totalTokens: 12345, - }; + statisticsSection: '## Statistics\n\n42 files | 1,000 lines', + }); const result = generateSkillMd(context); @@ -65,45 +79,45 @@ describe('skillStyle', () => { }); test('should end with newline', () => { - const context = { - skillName: 'test-skill', - skillDescription: 'Test description', - projectName: 'Test Project', - totalFiles: 1, - totalTokens: 100, - }; - - const result = generateSkillMd(context); + const result = generateSkillMd(createTestContext()); expect(result.endsWith('\n')).toBe(true); }); test('should include references to multiple files', () => { - const context = { - skillName: 'test-skill', - skillDescription: 'Test description', - projectName: 'Test Project', - totalFiles: 1, - totalTokens: 100, - }; - - const result = generateSkillMd(context); + const result = generateSkillMd(createTestContext()); expect(result).toContain('`references/summary.md`'); - expect(result).toContain('`references/structure.md`'); + expect(result).toContain('`references/project-structure.md`'); expect(result).toContain('`references/files.md`'); }); test('should not include git sections (skill output is for reference codebases)', () => { - const context = { - skillName: 'test-skill', - skillDescription: 'Test description', - projectName: 'Test Project', - totalFiles: 1, - totalTokens: 100, - }; - - const result = generateSkillMd(context); + const result = generateSkillMd(createTestContext()); expect(result).not.toContain('git-diffs.md'); expect(result).not.toContain('git-logs.md'); }); + + test('should include tech-stack reference when hasTechStack is true', () => { + const result = generateSkillMd(createTestContext({ hasTechStack: true })); + expect(result).toContain('`references/tech-stack.md`'); + }); + + test('should not include tech-stack reference when hasTechStack is false', () => { + const result = generateSkillMd(createTestContext({ hasTechStack: false })); + expect(result).not.toContain('tech-stack.md'); + }); + + test('should include statistics section', () => { + const result = generateSkillMd( + createTestContext({ + statisticsSection: '## Statistics\n\n10 files | 500 lines', + }), + ); + expect(result).toContain('## Statistics'); + }); + + test('should include total lines in header', () => { + const result = generateSkillMd(createTestContext({ totalLines: 5000 })); + expect(result).toContain('5000 lines'); + }); }); }); diff --git a/tests/core/output/skill/skillTechStack.test.ts b/tests/core/output/skill/skillTechStack.test.ts new file mode 100644 index 00000000..91358553 --- /dev/null +++ b/tests/core/output/skill/skillTechStack.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from 'vitest'; +import type { ProcessedFile } from '../../../../src/core/file/fileTypes.js'; +import { detectTechStack, generateTechStackMd } from '../../../../src/core/output/skill/skillTechStack.js'; + +describe('skillTechStack', () => { + describe('detectTechStack', () => { + test('should detect Node.js from package.json', () => { + const files: ProcessedFile[] = [ + { + path: 'package.json', + content: JSON.stringify({ + dependencies: { + react: '^18.2.0', + express: '^4.18.0', + }, + devDependencies: { + typescript: '^5.0.0', + }, + }), + }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.languages).toContain('Node.js'); + expect(result?.frameworks).toContain('React'); + expect(result?.frameworks).toContain('Express'); + expect(result?.frameworks).toContain('TypeScript'); + expect(result?.dependencies.length).toBeGreaterThan(0); + expect(result?.devDependencies.length).toBeGreaterThan(0); + }); + + test('should detect Python from requirements.txt', () => { + const files: ProcessedFile[] = [ + { + path: 'requirements.txt', + content: `django==4.2.0 +flask>=2.0.0 +fastapi +# comment +-r base.txt`, + }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.languages).toContain('Python'); + expect(result?.frameworks).toContain('Django'); + expect(result?.frameworks).toContain('Flask'); + expect(result?.frameworks).toContain('FastAPI'); + }); + + test('should detect Go from go.mod', () => { + const files: ProcessedFile[] = [ + { + path: 'go.mod', + content: `module example.com/myproject + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.0 + github.com/stretchr/testify v1.8.0 +)`, + }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.languages).toContain('Go'); + expect(result?.frameworks).toContain('Gin'); + }); + + test('should detect Rust from Cargo.toml', () => { + const files: ProcessedFile[] = [ + { + path: 'Cargo.toml', + content: `[package] +name = "myproject" +version = "0.1.0" + +[dependencies] +actix-web = "4.0" +tokio = { version = "1.0", features = ["full"] }`, + }, + ]; + + const result = detectTechStack(files); + + expect(result).not.toBeNull(); + expect(result?.languages).toContain('Rust'); + expect(result?.frameworks).toContain('Actix'); + expect(result?.frameworks).toContain('Tokio'); + }); + + test('should return null when no dependency files found', () => { + const files: ProcessedFile[] = [ + { path: 'src/index.ts', content: 'console.log("hello")' }, + { path: 'README.md', content: '# My Project' }, + ]; + + const result = detectTechStack(files); + expect(result).toBeNull(); + }); + + test('should ignore dependency files in subdirectories', () => { + const files: ProcessedFile[] = [ + { + path: 'packages/sub/package.json', + content: JSON.stringify({ dependencies: { lodash: '4.0.0' } }), + }, + ]; + + const result = detectTechStack(files); + expect(result).toBeNull(); + }); + + test('should detect package manager from packageManager field', () => { + const files: ProcessedFile[] = [ + { + path: 'package.json', + content: JSON.stringify({ + packageManager: 'pnpm@8.0.0', + dependencies: {}, + }), + }, + ]; + + const result = detectTechStack(files); + expect(result?.packageManager).toBe('pnpm'); + }); + }); + + describe('generateTechStackMd', () => { + test('should generate markdown with all sections', () => { + const techStack = { + languages: ['Node.js'], + frameworks: ['React', 'TypeScript'], + dependencies: [ + { name: 'react', version: '^18.2.0' }, + { name: 'react-dom', version: '^18.2.0' }, + ], + devDependencies: [{ name: 'typescript', version: '^5.0.0' }], + packageManager: 'npm', + }; + + const result = generateTechStackMd(techStack); + + expect(result).toContain('# Tech Stack'); + expect(result).toContain('## Languages'); + expect(result).toContain('- Node.js'); + expect(result).toContain('## Frameworks'); + expect(result).toContain('- React'); + expect(result).toContain('- TypeScript'); + expect(result).toContain('## Package Manager'); + expect(result).toContain('- npm'); + expect(result).toContain('## Dependencies'); + expect(result).toContain('- react (^18.2.0)'); + expect(result).toContain('## Dev Dependencies'); + expect(result).toContain('- typescript (^5.0.0)'); + }); + + test('should limit dependencies to 20', () => { + const techStack = { + languages: ['Node.js'], + frameworks: [], + dependencies: Array.from({ length: 25 }, (_, i) => ({ name: `dep-${i}`, version: '1.0.0' })), + devDependencies: [], + }; + + const result = generateTechStackMd(techStack); + + expect(result).toContain('... and 5 more'); + expect(result).not.toContain('dep-24'); + }); + + test('should handle empty sections', () => { + const techStack = { + languages: ['Node.js'], + frameworks: [], + dependencies: [], + devDependencies: [], + }; + + const result = generateTechStackMd(techStack); + + expect(result).toContain('# Tech Stack'); + expect(result).toContain('## Languages'); + expect(result).not.toContain('## Frameworks'); + expect(result).not.toContain('## Dependencies'); + }); + }); +});