feat(skill): Add tech-stack detection and file statistics to skill output

Add new features to improve skill output for understanding codebases:

- Add tech-stack.md: Auto-detect languages, frameworks, and dependencies
  from package.json, requirements.txt, go.mod, Cargo.toml, etc.
- Add file statistics section to SKILL.md with language breakdown
  and largest files list
- Rename structure.md to project-structure.md for clarity
- Add total lines count to SKILL.md header
This commit is contained in:
Kazuki Yamada
2025-12-07 23:29:15 +09:00
parent 8aa10c5ebc
commit 2a4209d42c
8 changed files with 1069 additions and 41 deletions
+22 -1
View File
@@ -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<ProcessedFile>): 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 {
+196
View File
@@ -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<string, string> = {
// 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<string, number>,
): StatisticsInfo => {
const statsByExt: Record<string, { fileCount: number; lineCount: number }> = {};
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');
};
+17 -6
View File
@@ -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: <path>\`) |
{{#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}}
`;
};
+405
View File
@@ -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<string, { language: string; parser: (content: string) => Partial<TechStackInfo> }> = {
'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<TechStackInfo> {
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<TechStackInfo> {
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<TechStackInfo> {
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<TechStackInfo> {
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<TechStackInfo> {
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<TechStackInfo> {
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<TechStackInfo> {
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<TechStackInfo> {
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<TechStackInfo> {
const dependencies: DependencyInfo[] = [];
const frameworks: string[] = [];
// Simple XML parsing for dependencies
const depMatches = content.matchAll(/<dependency>[\s\S]*?<artifactId>([^<]+)<\/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<TechStackInfo> {
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();
};
+9 -3
View File
@@ -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;
@@ -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');
});
});
});
+45 -31
View File
@@ -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');
});
});
});
@@ -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');
});
});
});