mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
FEAT: Added support for optional skill generation based on KuzuDB after initial repo analysis (npx gitnexus analyze --skills) (#171)
* calm fix 4 adding skills to repo [ISSUE #140] * inspect * unit and integration tests * fixed hardcoded cohesion miss * e2e tests for --skills flag for langauge/repo support * Cohesion test e2e tests
This commit is contained in:
parent
74c0e462c3
commit
03bfa3c4d9
16 changed files with 4249 additions and 33 deletions
|
|
@ -22,7 +22,7 @@ Run from the project root. This parses all source files, builds the knowledge gr
|
|||
| `--force` | Force full re-index even if up to date |
|
||||
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
||||
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale.
|
||||
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook runs `analyze` automatically after `git commit` and `git merge`, preserving embeddings if previously generated.
|
||||
|
||||
### status — Check index freshness
|
||||
|
||||
|
|
|
|||
3
.github/workflows/ci-integration.yml
vendored
3
.github/workflows/ci-integration.yml
vendored
|
|
@ -46,6 +46,7 @@ jobs:
|
|||
test-glob: >-
|
||||
test/integration/cli-e2e.test.ts
|
||||
test/integration/hooks-e2e.test.ts
|
||||
test/integration/skills-e2e.test.ts
|
||||
- test-group: standalone
|
||||
test-glob: >-
|
||||
test/integration/filesystem-walker.test.ts
|
||||
|
|
@ -53,7 +54,7 @@ jobs:
|
|||
test/integration/tree-sitter-languages.test.ts
|
||||
test/integration/worker-pool.test.ts
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: ./.github/actions/setup-gitnexus
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -48,6 +48,9 @@ coverage/
|
|||
# Claude Code worktrees
|
||||
.claude/worktrees/
|
||||
|
||||
# Claude code skills
|
||||
.claude/skills/generated/
|
||||
|
||||
# Assets (screenshots, images)
|
||||
assets/
|
||||
|
||||
|
|
|
|||
33
AGENTS.md
33
AGENTS.md
|
|
@ -1,7 +1,7 @@
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (1650 symbols, 4291 relationships, 125 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (1683 symbols, 4407 relationships, 127 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
|
@ -71,8 +71,33 @@ Before completing any code modification task, verify:
|
|||
|
||||
## CLI
|
||||
|
||||
- Re-index: `npx gitnexus analyze`
|
||||
- Check freshness: `npx gitnexus status`
|
||||
- Generate docs: `npx gitnexus wiki`
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
|
||||
| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` |
|
||||
| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` |
|
||||
| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` |
|
||||
| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
|
||||
| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
|
||||
| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` |
|
||||
| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` |
|
||||
| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` |
|
||||
| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` |
|
||||
| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` |
|
||||
| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` |
|
||||
| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` |
|
||||
| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` |
|
||||
| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
|
||||
| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` |
|
||||
| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` |
|
||||
| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` |
|
||||
| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` |
|
||||
| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
|
|
|||
33
CLAUDE.md
33
CLAUDE.md
|
|
@ -1,7 +1,7 @@
|
|||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **GitNexus** (1650 symbols, 4291 relationships, 125 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
This project is indexed by GitNexus as **GitNexus** (1683 symbols, 4407 relationships, 127 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||
|
||||
|
|
@ -71,8 +71,33 @@ Before completing any code modification task, verify:
|
|||
|
||||
## CLI
|
||||
|
||||
- Re-index: `npx gitnexus analyze`
|
||||
- Check freshness: `npx gitnexus status`
|
||||
- Generate docs: `npx gitnexus wiki`
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
| Work in the Ingestion area (135 symbols) | `.claude/skills/generated/ingestion/SKILL.md` |
|
||||
| Work in the Workers area (70 symbols) | `.claude/skills/generated/workers/SKILL.md` |
|
||||
| Work in the Cli area (63 symbols) | `.claude/skills/generated/cli/SKILL.md` |
|
||||
| Work in the Kuzu area (52 symbols) | `.claude/skills/generated/kuzu/SKILL.md` |
|
||||
| Work in the Wiki area (52 symbols) | `.claude/skills/generated/wiki/SKILL.md` |
|
||||
| Work in the Embeddings area (48 symbols) | `.claude/skills/generated/embeddings/SKILL.md` |
|
||||
| Work in the Components area (42 symbols) | `.claude/skills/generated/components/SKILL.md` |
|
||||
| Work in the Local area (36 symbols) | `.claude/skills/generated/local/SKILL.md` |
|
||||
| Work in the Storage area (36 symbols) | `.claude/skills/generated/storage/SKILL.md` |
|
||||
| Work in the Services area (35 symbols) | `.claude/skills/generated/services/SKILL.md` |
|
||||
| Work in the Mcp area (32 symbols) | `.claude/skills/generated/mcp/SKILL.md` |
|
||||
| Work in the Llm area (30 symbols) | `.claude/skills/generated/llm/SKILL.md` |
|
||||
| Work in the Eval area (18 symbols) | `.claude/skills/generated/eval/SKILL.md` |
|
||||
| Work in the Bridge area (15 symbols) | `.claude/skills/generated/bridge/SKILL.md` |
|
||||
| Work in the Hooks area (14 symbols) | `.claude/skills/generated/hooks/SKILL.md` |
|
||||
| Work in the Search area (11 symbols) | `.claude/skills/generated/search/SKILL.md` |
|
||||
| Work in the Environments area (11 symbols) | `.claude/skills/generated/environments/SKILL.md` |
|
||||
| Work in the Analysis area (10 symbols) | `.claude/skills/generated/analysis/SKILL.md` |
|
||||
| Work in the Agents area (9 symbols) | `.claude/skills/generated/agents/SKILL.md` |
|
||||
| Work in the Graph area (6 symbols) | `.claude/skills/generated/graph/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
|
|
|||
|
|
@ -135,6 +135,8 @@ claude mcp add gitnexus -- npx -y gitnexus@latest mcp
|
|||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --force # Force full re-index
|
||||
gitnexus analyze --skills # Generate repo-specific skill files from detected communities
|
||||
gitnexus analyze --skip-embeddings # Skip embedding generation (faster)
|
||||
gitnexus analyze --embeddings # Enable embedding generation (slower, better search)
|
||||
gitnexus analyze --verbose # Log skipped files when parsers are unavailable
|
||||
gitnexus mcp # Start MCP server (stdio) — serves all indexed repos
|
||||
|
|
@ -190,6 +192,10 @@ gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
|
|||
- **Impact Analysis** — Analyze blast radius before changes
|
||||
- **Refactoring** — Plan safe refactors using dependency mapping
|
||||
|
||||
**Repo-specific skills** generated with `--skills`:
|
||||
|
||||
When you run `gitnexus analyze --skills`, GitNexus detects the functional areas of your codebase (via Leiden community detection) and generates a `SKILL.md` file for each one under `.claude/skills/generated/`. Each skill describes a module's key files, entry points, execution flows, and cross-area connections — so your AI agent gets targeted context for the exact area of code you're working in. Skills are regenerated on each `--skills` run to stay current with the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Repo MCP Architecture
|
||||
|
|
|
|||
|
|
@ -330,25 +330,20 @@ const calculateCohesion = (memberIds: string[], graph: Graph): number => {
|
|||
|
||||
const memberSet = new Set(memberIds);
|
||||
let internalEdges = 0;
|
||||
|
||||
// Count edges within the community
|
||||
let totalEdges = 0;
|
||||
|
||||
// Count internal vs total edges for community members
|
||||
memberIds.forEach(nodeId => {
|
||||
if (graph.hasNode(nodeId)) {
|
||||
graph.forEachNeighbor(nodeId, neighbor => {
|
||||
totalEdges++;
|
||||
if (memberSet.has(neighbor)) {
|
||||
internalEdges++;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Each edge is counted twice (once from each end), so divide by 2
|
||||
internalEdges = internalEdges / 2;
|
||||
|
||||
// Maximum possible internal edges for n nodes: n*(n-1)/2
|
||||
const maxPossibleEdges = (memberIds.length * (memberIds.length - 1)) / 2;
|
||||
|
||||
if (maxPossibleEdges === 0) return 1.0;
|
||||
|
||||
return Math.min(1.0, internalEdges / maxPossibleEdges);
|
||||
|
||||
if (totalEdges === 0) return 1.0;
|
||||
return Math.min(1.0, internalEdges / totalEdges);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { type GeneratedSkillInfo } from './skill-gen.js';
|
||||
|
||||
// ESM equivalent of __dirname
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
|
@ -37,7 +38,22 @@ const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
|
|||
* - Exact tool commands with parameters — vague directives get ignored
|
||||
* - Self-review checklist — forces model to verify its own work
|
||||
*/
|
||||
function generateGitNexusContent(projectName: string, stats: RepoStats): string {
|
||||
function generateGitNexusContent(projectName: string, stats: RepoStats, generatedSkills?: GeneratedSkillInfo[]): string {
|
||||
const generatedRows = (generatedSkills && generatedSkills.length > 0)
|
||||
? generatedSkills.map(s =>
|
||||
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`
|
||||
).join('\n')
|
||||
: '';
|
||||
|
||||
const skillsTable = `| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
|
||||
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md\` |
|
||||
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus/gitnexus-debugging/SKILL.md\` |
|
||||
| Rename / extract / split / refactor | \`.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md\` |
|
||||
| Tools, resources, schema reference | \`.claude/skills/gitnexus/gitnexus-guide/SKILL.md\` |
|
||||
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |${generatedRows ? '\n' + generatedRows : ''}`;
|
||||
|
||||
return `${GITNEXUS_START_MARKER}
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
|
|
@ -129,9 +145,7 @@ To check whether embeddings exist, inspect \`.gitnexus/meta.json\` — the \`sta
|
|||
|
||||
## CLI
|
||||
|
||||
- Re-index: \`npx gitnexus analyze\`
|
||||
- Check freshness: \`npx gitnexus status\`
|
||||
- Generate docs: \`npx gitnexus wiki\`
|
||||
${skillsTable}
|
||||
|
||||
${GITNEXUS_END_MARKER}`;
|
||||
}
|
||||
|
|
@ -270,9 +284,10 @@ export async function generateAIContextFiles(
|
|||
repoPath: string,
|
||||
_storagePath: string,
|
||||
projectName: string,
|
||||
stats: RepoStats
|
||||
stats: RepoStats,
|
||||
generatedSkills?: GeneratedSkillInfo[]
|
||||
): Promise<{ files: string[] }> {
|
||||
const content = generateGitNexusContent(projectName, stats);
|
||||
const content = generateGitNexusContent(projectName, stats, generatedSkills);
|
||||
const createdFiles: string[] = [];
|
||||
|
||||
// Create AGENTS.md (standard for Cursor, Windsurf, OpenCode, Cline, etc.)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { initKuzu, loadGraphToKuzu, getKuzuStats, executeQuery, executeWithReuse
|
|||
import { getStoragePaths, saveMeta, loadMeta, addToGitignore, registerRepo, getGlobalRegistryPath } from '../storage/repo-manager.js';
|
||||
import { getCurrentCommit, isGitRepo, getGitRoot } from '../storage/git.js';
|
||||
import { generateAIContextFiles } from './ai-context.js';
|
||||
import { generateSkillFiles, type GeneratedSkillInfo } from './skill-gen.js';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ function ensureHeap(): boolean {
|
|||
export interface AnalyzeOptions {
|
||||
force?: boolean;
|
||||
embeddings?: boolean;
|
||||
skills?: boolean;
|
||||
verbose?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +104,7 @@ export const analyzeCommand = async (
|
|||
const currentCommit = getCurrentCommit(repoPath);
|
||||
const existingMeta = await loadMeta(storagePath);
|
||||
|
||||
if (existingMeta && !options?.force && existingMeta.lastCommit === currentCommit) {
|
||||
if (existingMeta && !options?.force && !options?.skills && existingMeta.lastCommit === currentCommit) {
|
||||
console.log(' Already up to date\n');
|
||||
return;
|
||||
}
|
||||
|
|
@ -316,6 +318,13 @@ export const analyzeCommand = async (
|
|||
aggregatedClusterCount = Array.from(groups.values()).filter(count => count >= 5).length;
|
||||
}
|
||||
|
||||
let generatedSkills: GeneratedSkillInfo[] = [];
|
||||
if (options?.skills && pipelineResult.communityResult) {
|
||||
updateBar(99, 'Generating skill files...');
|
||||
const skillResult = await generateSkillFiles(repoPath, projectName, pipelineResult);
|
||||
generatedSkills = skillResult.skills;
|
||||
}
|
||||
|
||||
const aiContext = await generateAIContextFiles(repoPath, storagePath, projectName, {
|
||||
files: pipelineResult.totalFileCount,
|
||||
nodes: stats.nodes,
|
||||
|
|
@ -323,7 +332,7 @@ export const analyzeCommand = async (
|
|||
communities: pipelineResult.communityResult?.stats.totalCommunities,
|
||||
clusters: aggregatedClusterCount,
|
||||
processes: pipelineResult.processResult?.stats.totalProcesses,
|
||||
});
|
||||
}, generatedSkills);
|
||||
|
||||
await closeKuzu();
|
||||
// Note: we intentionally do NOT call disposeEmbedder() here.
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ program
|
|||
.description('Index a repository (full analysis)')
|
||||
.option('-f, --force', 'Force full re-index even if up to date')
|
||||
.option('--embeddings', 'Enable embedding generation for semantic search (off by default)')
|
||||
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
|
||||
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
|
||||
.option('--skills', 'Generate repo-specific skill files from detected communities')
|
||||
.option('-v, --verbose', 'Enable verbose ingestion warnings (default: false)')
|
||||
.action(createLazyAction(() => import('./analyze.js'), 'analyzeCommand'));
|
||||
|
||||
program
|
||||
.command('serve')
|
||||
|
|
|
|||
712
gitnexus/src/cli/skill-gen.ts
Normal file
712
gitnexus/src/cli/skill-gen.ts
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
/**
|
||||
* Skill File Generator
|
||||
*
|
||||
* Generates repo-specific SKILL.md files from detected Leiden communities.
|
||||
* Each significant community becomes a skill that describes a functional area
|
||||
* of the codebase, including key files, entry points, execution flows, and
|
||||
* cross-community connections.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { PipelineResult } from '../types/pipeline.js';
|
||||
import { CommunityNode, CommunityMembership } from '../core/ingestion/community-processor.js';
|
||||
import { ProcessNode } from '../core/ingestion/process-processor.js';
|
||||
import { GraphNode, KnowledgeGraph } from '../core/graph/types.js';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface GeneratedSkillInfo {
|
||||
name: string;
|
||||
label: string;
|
||||
symbolCount: number;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
interface AggregatedCommunity {
|
||||
label: string;
|
||||
rawIds: string[];
|
||||
symbolCount: number;
|
||||
cohesion: number;
|
||||
}
|
||||
|
||||
interface MemberSymbol {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
filePath: string;
|
||||
startLine: number;
|
||||
isExported: boolean;
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
relativePath: string;
|
||||
symbols: string[];
|
||||
}
|
||||
|
||||
interface CrossConnection {
|
||||
targetLabel: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN EXPORT
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Generate repo-specific skill files from detected communities
|
||||
* @param {string} repoPath - Absolute path to the repository root
|
||||
* @param {string} projectName - Human-readable project name
|
||||
* @param {PipelineResult} pipelineResult - In-memory pipeline data with communities, processes, graph
|
||||
* @returns {Promise<{ skills: GeneratedSkillInfo[], outputPath: string }>} Generated skill metadata
|
||||
*/
|
||||
export const generateSkillFiles = async (
|
||||
repoPath: string,
|
||||
projectName: string,
|
||||
pipelineResult: PipelineResult
|
||||
): Promise<{ skills: GeneratedSkillInfo[]; outputPath: string }> => {
|
||||
const { communityResult, processResult, graph } = pipelineResult;
|
||||
const outputDir = path.join(repoPath, '.claude', 'skills', 'generated');
|
||||
|
||||
if (!communityResult || !communityResult.memberships.length) {
|
||||
console.log('\n Skills: no communities detected, skipping skill generation');
|
||||
return { skills: [], outputPath: outputDir };
|
||||
}
|
||||
|
||||
console.log('\n Generating repo-specific skills...');
|
||||
|
||||
// Step 1: Build communities from memberships (not the filtered communities array).
|
||||
// The community processor skips singletons from its communities array but memberships
|
||||
// include ALL assignments. For repos with sparse CALLS edges, the communities array
|
||||
// can be empty while memberships still has useful groupings.
|
||||
const communities = communityResult.communities.length > 0
|
||||
? communityResult.communities
|
||||
: buildCommunitiesFromMemberships(communityResult.memberships, graph, repoPath);
|
||||
|
||||
const aggregated = aggregateCommunities(communities);
|
||||
|
||||
// Step 2: Filter to significant communities
|
||||
// Keep communities with >= 3 symbols after aggregation.
|
||||
const significant = aggregated
|
||||
.filter(c => c.symbolCount >= 3)
|
||||
.sort((a, b) => b.symbolCount - a.symbolCount)
|
||||
.slice(0, 20);
|
||||
|
||||
if (significant.length === 0) {
|
||||
console.log('\n Skills: no significant communities found (all below 3-symbol threshold)');
|
||||
return { skills: [], outputPath: outputDir };
|
||||
}
|
||||
|
||||
// Step 3: Build lookup maps
|
||||
const membershipsByComm = buildMembershipMap(communityResult.memberships);
|
||||
const nodeIdToCommunityLabel = buildNodeCommunityLabelMap(
|
||||
communityResult.memberships,
|
||||
communities
|
||||
);
|
||||
|
||||
// Step 4: Clear and recreate output directory
|
||||
try {
|
||||
await fs.rm(outputDir, { recursive: true, force: true });
|
||||
} catch { /* may not exist */ }
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
// Step 5: Generate skill files
|
||||
const skills: GeneratedSkillInfo[] = [];
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
for (const community of significant) {
|
||||
// Gather member symbols
|
||||
const members = gatherMembers(community.rawIds, membershipsByComm, graph);
|
||||
if (members.length === 0) continue;
|
||||
|
||||
// Gather file info
|
||||
const files = gatherFiles(members, repoPath);
|
||||
|
||||
// Gather entry points
|
||||
const entryPoints = gatherEntryPoints(members);
|
||||
|
||||
// Gather execution flows
|
||||
const flows = gatherFlows(community.rawIds, processResult?.processes || []);
|
||||
|
||||
// Gather cross-community connections
|
||||
const connections = gatherCrossConnections(
|
||||
community.rawIds,
|
||||
community.label,
|
||||
membershipsByComm,
|
||||
nodeIdToCommunityLabel,
|
||||
graph
|
||||
);
|
||||
|
||||
// Generate kebab name
|
||||
const kebabName = toKebabName(community.label, usedNames);
|
||||
usedNames.add(kebabName);
|
||||
|
||||
// Generate SKILL.md content
|
||||
const content = renderSkillMarkdown(
|
||||
community,
|
||||
projectName,
|
||||
members,
|
||||
files,
|
||||
entryPoints,
|
||||
flows,
|
||||
connections,
|
||||
kebabName
|
||||
);
|
||||
|
||||
// Write file
|
||||
const skillDir = path.join(outputDir, kebabName);
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
|
||||
|
||||
const info: GeneratedSkillInfo = {
|
||||
name: kebabName,
|
||||
label: community.label,
|
||||
symbolCount: community.symbolCount,
|
||||
fileCount: files.length,
|
||||
};
|
||||
skills.push(info);
|
||||
|
||||
console.log(` \u2713 ${community.label} (${community.symbolCount} symbols, ${files.length} files)`);
|
||||
}
|
||||
|
||||
console.log(`\n ${skills.length} skills generated \u2192 .claude/skills/generated/`);
|
||||
|
||||
return { skills, outputPath: outputDir };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// FALLBACK COMMUNITY BUILDER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Build CommunityNode-like objects from raw memberships when the community
|
||||
* processor's communities array is empty (all singletons were filtered out)
|
||||
* @param {CommunityMembership[]} memberships - All node-to-community assignments
|
||||
* @param {KnowledgeGraph} graph - The knowledge graph for resolving node metadata
|
||||
* @param {string} repoPath - Repository root for path normalization
|
||||
* @returns {CommunityNode[]} Synthetic community nodes built from membership data
|
||||
*/
|
||||
const buildCommunitiesFromMemberships = (
|
||||
memberships: CommunityMembership[],
|
||||
graph: KnowledgeGraph,
|
||||
repoPath: string
|
||||
): CommunityNode[] => {
|
||||
// Group memberships by communityId
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const m of memberships) {
|
||||
const arr = groups.get(m.communityId);
|
||||
if (arr) {
|
||||
arr.push(m.nodeId);
|
||||
} else {
|
||||
groups.set(m.communityId, [m.nodeId]);
|
||||
}
|
||||
}
|
||||
|
||||
const communities: CommunityNode[] = [];
|
||||
|
||||
for (const [commId, nodeIds] of groups) {
|
||||
// Derive a heuristic label from the most common parent directory
|
||||
const folderCounts = new Map<string, number>();
|
||||
for (const nodeId of nodeIds) {
|
||||
const node = graph.getNode(nodeId);
|
||||
if (!node?.properties.filePath) continue;
|
||||
const normalized = node.properties.filePath.replace(/\\/g, '/');
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
const folder = parts[parts.length - 2];
|
||||
if (!['src', 'lib', 'core', 'utils', 'common', 'shared', 'helpers'].includes(folder.toLowerCase())) {
|
||||
folderCounts.set(folder, (folderCounts.get(folder) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bestFolder = '';
|
||||
let bestCount = 0;
|
||||
for (const [folder, count] of folderCounts) {
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
bestFolder = folder;
|
||||
}
|
||||
}
|
||||
|
||||
const label = bestFolder
|
||||
? bestFolder.charAt(0).toUpperCase() + bestFolder.slice(1)
|
||||
: `Cluster_${commId.replace('comm_', '')}`;
|
||||
|
||||
// Compute cohesion as internal-edge ratio (matches backend calculateCohesion).
|
||||
// For each member node, count edges that stay inside the community vs total.
|
||||
const nodeSet = new Set(nodeIds);
|
||||
let internalEdges = 0;
|
||||
let totalEdges = 0;
|
||||
graph.forEachRelationship(rel => {
|
||||
if (nodeSet.has(rel.sourceId)) {
|
||||
totalEdges++;
|
||||
if (nodeSet.has(rel.targetId)) internalEdges++;
|
||||
}
|
||||
});
|
||||
const cohesion = totalEdges > 0 ? Math.min(1.0, internalEdges / totalEdges) : 1.0;
|
||||
|
||||
communities.push({
|
||||
id: commId,
|
||||
label,
|
||||
heuristicLabel: label,
|
||||
cohesion,
|
||||
symbolCount: nodeIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
return communities.sort((a, b) => b.symbolCount - a.symbolCount);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AGGREGATION
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Aggregate raw Leiden communities by heuristicLabel
|
||||
* @param {CommunityNode[]} communities - Raw community nodes from Leiden detection
|
||||
* @returns {AggregatedCommunity[]} Aggregated communities grouped by label
|
||||
*/
|
||||
const aggregateCommunities = (communities: CommunityNode[]): AggregatedCommunity[] => {
|
||||
const groups = new Map<string, {
|
||||
rawIds: string[];
|
||||
totalSymbols: number;
|
||||
weightedCohesion: number;
|
||||
}>();
|
||||
|
||||
for (const c of communities) {
|
||||
const label = c.heuristicLabel || c.label || 'Unknown';
|
||||
const symbols = c.symbolCount || 0;
|
||||
const cohesion = c.cohesion || 0;
|
||||
const existing = groups.get(label);
|
||||
|
||||
if (!existing) {
|
||||
groups.set(label, {
|
||||
rawIds: [c.id],
|
||||
totalSymbols: symbols,
|
||||
weightedCohesion: cohesion * symbols,
|
||||
});
|
||||
} else {
|
||||
existing.rawIds.push(c.id);
|
||||
existing.totalSymbols += symbols;
|
||||
existing.weightedCohesion += cohesion * symbols;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([label, g]) => ({
|
||||
label,
|
||||
rawIds: g.rawIds,
|
||||
symbolCount: g.totalSymbols,
|
||||
cohesion: g.totalSymbols > 0 ? g.weightedCohesion / g.totalSymbols : 0,
|
||||
}));
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// LOOKUP MAP BUILDERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Build a map from communityId to member nodeIds
|
||||
* @param {CommunityMembership[]} memberships - All membership records
|
||||
* @returns {Map<string, string[]>} Map of communityId -> nodeId[]
|
||||
*/
|
||||
const buildMembershipMap = (memberships: CommunityMembership[]): Map<string, string[]> => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const m of memberships) {
|
||||
const arr = map.get(m.communityId);
|
||||
if (arr) {
|
||||
arr.push(m.nodeId);
|
||||
} else {
|
||||
map.set(m.communityId, [m.nodeId]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Build a map from nodeId to aggregated community label
|
||||
* @param {CommunityMembership[]} memberships - All membership records
|
||||
* @param {CommunityNode[]} communities - Community nodes with labels
|
||||
* @returns {Map<string, string>} Map of nodeId -> community label
|
||||
*/
|
||||
const buildNodeCommunityLabelMap = (
|
||||
memberships: CommunityMembership[],
|
||||
communities: CommunityNode[]
|
||||
): Map<string, string> => {
|
||||
const commIdToLabel = new Map<string, string>();
|
||||
for (const c of communities) {
|
||||
commIdToLabel.set(c.id, c.heuristicLabel || c.label || 'Unknown');
|
||||
}
|
||||
|
||||
const map = new Map<string, string>();
|
||||
for (const m of memberships) {
|
||||
const label = commIdToLabel.get(m.communityId);
|
||||
if (label) {
|
||||
map.set(m.nodeId, label);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// DATA GATHERING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Gather member symbols for an aggregated community
|
||||
* @param {string[]} rawIds - Raw community IDs belonging to this aggregated community
|
||||
* @param {Map<string, string[]>} membershipsByComm - communityId -> nodeIds
|
||||
* @param {KnowledgeGraph} graph - The knowledge graph
|
||||
* @returns {MemberSymbol[]} Array of member symbol information
|
||||
*/
|
||||
const gatherMembers = (
|
||||
rawIds: string[],
|
||||
membershipsByComm: Map<string, string[]>,
|
||||
graph: KnowledgeGraph
|
||||
): MemberSymbol[] => {
|
||||
const seen = new Set<string>();
|
||||
const members: MemberSymbol[] = [];
|
||||
|
||||
for (const commId of rawIds) {
|
||||
const nodeIds = membershipsByComm.get(commId) || [];
|
||||
for (const nodeId of nodeIds) {
|
||||
if (seen.has(nodeId)) continue;
|
||||
seen.add(nodeId);
|
||||
|
||||
const node = graph.getNode(nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
members.push({
|
||||
id: node.id,
|
||||
name: node.properties.name,
|
||||
label: node.label,
|
||||
filePath: node.properties.filePath || '',
|
||||
startLine: node.properties.startLine || 0,
|
||||
isExported: node.properties.isExported === true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return members;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Gather deduplicated file info with per-file symbol names
|
||||
* @param {MemberSymbol[]} members - Member symbols
|
||||
* @param {string} repoPath - Repository root for relative path computation
|
||||
* @returns {FileInfo[]} Sorted by symbol count descending
|
||||
*/
|
||||
const gatherFiles = (members: MemberSymbol[], repoPath: string): FileInfo[] => {
|
||||
const fileMap = new Map<string, string[]>();
|
||||
|
||||
for (const m of members) {
|
||||
if (!m.filePath) continue;
|
||||
const rel = toRelativePath(m.filePath, repoPath);
|
||||
const arr = fileMap.get(rel);
|
||||
if (arr) {
|
||||
arr.push(m.name);
|
||||
} else {
|
||||
fileMap.set(rel, [m.name]);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(fileMap.entries())
|
||||
.map(([relativePath, symbols]) => ({ relativePath, symbols }))
|
||||
.sort((a, b) => b.symbols.length - a.symbols.length);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Gather exported entry points prioritized by type
|
||||
* @param {MemberSymbol[]} members - Member symbols
|
||||
* @returns {MemberSymbol[]} Exported symbols sorted by type priority
|
||||
*/
|
||||
const gatherEntryPoints = (members: MemberSymbol[]): MemberSymbol[] => {
|
||||
const typePriority: Record<string, number> = {
|
||||
Function: 0,
|
||||
Class: 1,
|
||||
Method: 2,
|
||||
Interface: 3,
|
||||
};
|
||||
|
||||
return members
|
||||
.filter(m => m.isExported)
|
||||
.sort((a, b) => {
|
||||
const pa = typePriority[a.label] ?? 99;
|
||||
const pb = typePriority[b.label] ?? 99;
|
||||
return pa - pb;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Gather execution flows touching this community
|
||||
* @param {string[]} rawIds - Raw community IDs for this aggregated community
|
||||
* @param {ProcessNode[]} processes - All detected processes
|
||||
* @returns {ProcessNode[]} Processes whose communities intersect rawIds, sorted by stepCount
|
||||
*/
|
||||
const gatherFlows = (rawIds: string[], processes: ProcessNode[]): ProcessNode[] => {
|
||||
const rawIdSet = new Set(rawIds);
|
||||
|
||||
return processes
|
||||
.filter(proc => proc.communities.some(cid => rawIdSet.has(cid)))
|
||||
.sort((a, b) => b.stepCount - a.stepCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Gather cross-community call connections
|
||||
* @param {string[]} rawIds - Raw community IDs for this aggregated community
|
||||
* @param {string} ownLabel - This community's aggregated label
|
||||
* @param {Map<string, string[]>} membershipsByComm - communityId -> nodeIds
|
||||
* @param {Map<string, string>} nodeIdToCommunityLabel - nodeId -> community label
|
||||
* @param {KnowledgeGraph} graph - The knowledge graph
|
||||
* @returns {CrossConnection[]} Aggregated cross-community connections sorted by count
|
||||
*/
|
||||
const gatherCrossConnections = (
|
||||
rawIds: string[],
|
||||
ownLabel: string,
|
||||
membershipsByComm: Map<string, string[]>,
|
||||
nodeIdToCommunityLabel: Map<string, string>,
|
||||
graph: KnowledgeGraph
|
||||
): CrossConnection[] => {
|
||||
// Collect all node IDs in this aggregated community
|
||||
const ownNodeIds = new Set<string>();
|
||||
for (const commId of rawIds) {
|
||||
const nodeIds = membershipsByComm.get(commId) || [];
|
||||
for (const nid of nodeIds) {
|
||||
ownNodeIds.add(nid);
|
||||
}
|
||||
}
|
||||
|
||||
// Count outgoing CALLS to nodes in different communities
|
||||
const targetCounts = new Map<string, number>();
|
||||
|
||||
graph.forEachRelationship(rel => {
|
||||
if (rel.type !== 'CALLS') return;
|
||||
if (!ownNodeIds.has(rel.sourceId)) return;
|
||||
if (ownNodeIds.has(rel.targetId)) return; // same community
|
||||
|
||||
const targetLabel = nodeIdToCommunityLabel.get(rel.targetId);
|
||||
if (!targetLabel || targetLabel === ownLabel) return;
|
||||
|
||||
targetCounts.set(targetLabel, (targetCounts.get(targetLabel) || 0) + 1);
|
||||
});
|
||||
|
||||
return Array.from(targetCounts.entries())
|
||||
.map(([targetLabel, count]) => ({ targetLabel, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MARKDOWN RENDERING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Render SKILL.md content for a single community
|
||||
* @param {AggregatedCommunity} community - The aggregated community data
|
||||
* @param {string} projectName - Project name for the description
|
||||
* @param {MemberSymbol[]} members - All member symbols
|
||||
* @param {FileInfo[]} files - File info with symbol names
|
||||
* @param {MemberSymbol[]} entryPoints - Exported entry point symbols
|
||||
* @param {ProcessNode[]} flows - Execution flows touching this community
|
||||
* @param {CrossConnection[]} connections - Cross-community connections
|
||||
* @param {string} kebabName - Kebab-case name for the skill
|
||||
* @returns {string} Full SKILL.md content
|
||||
*/
|
||||
const renderSkillMarkdown = (
|
||||
community: AggregatedCommunity,
|
||||
projectName: string,
|
||||
members: MemberSymbol[],
|
||||
files: FileInfo[],
|
||||
entryPoints: MemberSymbol[],
|
||||
flows: ProcessNode[],
|
||||
connections: CrossConnection[],
|
||||
kebabName: string
|
||||
): string => {
|
||||
const cohesionPct = Math.round(community.cohesion * 100);
|
||||
|
||||
// Dominant directory: most common top-level directory
|
||||
const dominantDir = getDominantDirectory(files);
|
||||
|
||||
// Top symbol names for "When to Use"
|
||||
const topNames = entryPoints.slice(0, 3).map(e => e.name);
|
||||
if (topNames.length === 0) {
|
||||
// Fallback to any members
|
||||
topNames.push(...members.slice(0, 3).map(m => m.name));
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Frontmatter
|
||||
lines.push('---');
|
||||
lines.push(`name: ${kebabName}`);
|
||||
lines.push(`description: "Skill for the ${community.label} area of ${projectName}. ${community.symbolCount} symbols across ${files.length} files."`);
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
// Title
|
||||
lines.push(`# ${community.label}`);
|
||||
lines.push('');
|
||||
lines.push(`${community.symbolCount} symbols | ${files.length} files | Cohesion: ${cohesionPct}%`);
|
||||
lines.push('');
|
||||
|
||||
// When to Use
|
||||
lines.push('## When to Use');
|
||||
lines.push('');
|
||||
if (dominantDir) {
|
||||
lines.push(`- Working with code in \`${dominantDir}/\``);
|
||||
}
|
||||
if (topNames.length > 0) {
|
||||
lines.push(`- Understanding how ${topNames.join(', ')} work`);
|
||||
}
|
||||
lines.push(`- Modifying ${community.label.toLowerCase()}-related functionality`);
|
||||
lines.push('');
|
||||
|
||||
// Key Files (top 10)
|
||||
lines.push('## Key Files');
|
||||
lines.push('');
|
||||
lines.push('| File | Symbols |');
|
||||
lines.push('|------|---------|');
|
||||
for (const f of files.slice(0, 10)) {
|
||||
const symbolList = f.symbols.slice(0, 5).join(', ');
|
||||
const suffix = f.symbols.length > 5 ? ` (+${f.symbols.length - 5})` : '';
|
||||
lines.push(`| \`${f.relativePath}\` | ${symbolList}${suffix} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Entry Points (top 5)
|
||||
if (entryPoints.length > 0) {
|
||||
lines.push('## Entry Points');
|
||||
lines.push('');
|
||||
lines.push('Start here when exploring this area:');
|
||||
lines.push('');
|
||||
for (const ep of entryPoints.slice(0, 5)) {
|
||||
lines.push(`- **\`${ep.name}\`** (${ep.label}) \u2014 \`${ep.filePath}:${ep.startLine}\``);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Key Symbols (top 20, exported first, then by type)
|
||||
lines.push('## Key Symbols');
|
||||
lines.push('');
|
||||
lines.push('| Symbol | Type | File | Line |');
|
||||
lines.push('|--------|------|------|------|');
|
||||
const sortedMembers = [...members].sort((a, b) => {
|
||||
if (a.isExported !== b.isExported) return a.isExported ? -1 : 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
for (const m of sortedMembers.slice(0, 20)) {
|
||||
lines.push(`| \`${m.name}\` | ${m.label} | \`${m.filePath}\` | ${m.startLine} |`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Execution Flows
|
||||
if (flows.length > 0) {
|
||||
lines.push('## Execution Flows');
|
||||
lines.push('');
|
||||
lines.push('| Flow | Type | Steps |');
|
||||
lines.push('|------|------|-------|');
|
||||
for (const f of flows.slice(0, 10)) {
|
||||
lines.push(`| \`${f.heuristicLabel}\` | ${f.processType} | ${f.stepCount} |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Connected Areas
|
||||
if (connections.length > 0) {
|
||||
lines.push('## Connected Areas');
|
||||
lines.push('');
|
||||
lines.push('| Area | Connections |');
|
||||
lines.push('|------|-------------|');
|
||||
for (const c of connections.slice(0, 8)) {
|
||||
lines.push(`| ${c.targetLabel} | ${c.count} calls |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// How to Explore
|
||||
const firstEntry = entryPoints.length > 0 ? entryPoints[0].name : (members.length > 0 ? members[0].name : community.label);
|
||||
lines.push('## How to Explore');
|
||||
lines.push('');
|
||||
lines.push(`1. \`gitnexus_context({name: "${firstEntry}"})\` \u2014 see callers and callees`);
|
||||
lines.push(`2. \`gitnexus_query({query: "${community.label.toLowerCase()}"})\` \u2014 find related execution flows`);
|
||||
lines.push('3. Read key files listed above for implementation details');
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// UTILITY HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Convert a community label to a kebab-case directory name
|
||||
* @param {string} label - The community label
|
||||
* @param {Set<string>} usedNames - Already-used names for collision detection
|
||||
* @returns {string} Unique kebab-case name capped at 50 characters
|
||||
*/
|
||||
const toKebabName = (label: string, usedNames: Set<string>): string => {
|
||||
let name = label
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 50);
|
||||
|
||||
if (!name) name = 'skill';
|
||||
|
||||
let candidate = name;
|
||||
let counter = 2;
|
||||
while (usedNames.has(candidate)) {
|
||||
candidate = `${name}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Convert an absolute or repo-relative file path to a clean relative path
|
||||
* @param {string} filePath - The file path from the graph node
|
||||
* @param {string} repoPath - Repository root path
|
||||
* @returns {string} Relative path using forward slashes
|
||||
*/
|
||||
const toRelativePath = (filePath: string, repoPath: string): string => {
|
||||
// Normalize to forward slashes for cross-platform consistency
|
||||
const normalizedFile = filePath.replace(/\\/g, '/');
|
||||
const normalizedRepo = repoPath.replace(/\\/g, '/');
|
||||
|
||||
if (normalizedFile.startsWith(normalizedRepo)) {
|
||||
return normalizedFile.slice(normalizedRepo.length).replace(/^\//, '');
|
||||
}
|
||||
// Already relative or different root
|
||||
return normalizedFile.replace(/^\//, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Find the dominant (most common) top-level directory across files
|
||||
* @param {FileInfo[]} files - File info entries
|
||||
* @returns {string | null} Most common directory or null
|
||||
*/
|
||||
const getDominantDirectory = (files: FileInfo[]): string | null => {
|
||||
const dirCounts = new Map<string, number>();
|
||||
|
||||
for (const f of files) {
|
||||
const parts = f.relativePath.split('/');
|
||||
if (parts.length >= 2) {
|
||||
const dir = parts[0];
|
||||
dirCounts.set(dir, (dirCounts.get(dir) || 0) + f.symbols.length);
|
||||
}
|
||||
}
|
||||
|
||||
let best: string | null = null;
|
||||
let bestCount = 0;
|
||||
for (const [dir, count] of dirCounts) {
|
||||
if (count > bestCount) {
|
||||
bestCount = count;
|
||||
best = dir;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
};
|
||||
2420
gitnexus/test/integration/skills-e2e.test.ts
Normal file
2420
gitnexus/test/integration/skills-e2e.test.ts
Normal file
File diff suppressed because it is too large
Load diff
292
gitnexus/test/unit/cohesion-consistency.test.ts
Normal file
292
gitnexus/test/unit/cohesion-consistency.test.ts
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
/**
|
||||
* Unit tests for cohesion formula consistency.
|
||||
*
|
||||
* Verifies that calculateCohesion (module-private) uses the internal edge ratio
|
||||
* formula: internalEdges / totalEdges, NOT graph density (internalEdges / maxPossibleEdges).
|
||||
*
|
||||
* Since calculateCohesion is not exported, all tests exercise it indirectly through
|
||||
* processCommunities — the public export. Graphs are built so that Leiden's community
|
||||
* assignment is deterministic (disconnected cliques with strong internal connectivity).
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import type { GraphNode, GraphRelationship } from '../../src/core/graph/types.js';
|
||||
import { processCommunities } from '../../src/core/ingestion/community-processor.js';
|
||||
|
||||
// ============================================================================
|
||||
// FIXTURE HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/** Create a GraphNode with commonly-needed properties */
|
||||
function makeNode(
|
||||
id: string,
|
||||
name: string,
|
||||
label: GraphNode['label'],
|
||||
filePath: string,
|
||||
): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
properties: { name, filePath, startLine: 1, endLine: 10, isExported: false },
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a CALLS relationship between two nodes */
|
||||
function makeRel(
|
||||
id: string,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
): GraphRelationship {
|
||||
return { id, sourceId, targetId, type: 'CALLS', confidence: 1.0, reason: '' };
|
||||
}
|
||||
|
||||
/** Add a fully-connected clique of Function nodes to the graph */
|
||||
function addClique(
|
||||
graph: ReturnType<typeof createKnowledgeGraph>,
|
||||
prefix: string,
|
||||
folder: string,
|
||||
size: number,
|
||||
): string[] {
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < size; i++) {
|
||||
const id = `fn:${prefix}${i}`;
|
||||
ids.push(id);
|
||||
graph.addNode(makeNode(id, `${prefix}Fn${i}`, 'Function', `/src/${folder}/f${i}.ts`));
|
||||
}
|
||||
// Fully connect all pairs
|
||||
let relIdx = 0;
|
||||
for (let i = 0; i < size; i++) {
|
||||
for (let j = i + 1; j < size; j++) {
|
||||
graph.addRelationship(makeRel(`rel:${prefix}_${relIdx++}`, ids[i], ids[j]));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS
|
||||
// ============================================================================
|
||||
|
||||
describe('calculateCohesion — internal edge ratio', () => {
|
||||
/**
|
||||
* Build a 4-node fully connected clique with 2 external boundary edges.
|
||||
* For the clique community:
|
||||
* - 4 nodes, 6 internal edges (undirected)
|
||||
* - 2 external edges (one from node0, one from node1 to outside nodes)
|
||||
* - Each undirected edge is traversed twice in forEachNeighbor
|
||||
* - Internal traversals: 6 edges * 2 = 12 (each internal edge counted from both endpoints)
|
||||
* BUT only edges where BOTH endpoints are in the clique count. node0 has 3 internal + 1 external neighbor,
|
||||
* node1 has 3 internal + 1 external neighbor, node2 has 3 internal, node3 has 3 internal.
|
||||
* - Total neighbor traversals from clique members: (3+1) + (3+1) + 3 + 3 = 14
|
||||
* - Internal traversals: 3 + 3 + 3 + 3 = 12
|
||||
* - Edge ratio: 12 / 14 = 0.857...
|
||||
* - Graph density would be: 6 / (4*3/2) = 6/6 = 1.0
|
||||
* - This discriminates: if cohesion < 1.0, it's edge ratio; if 1.0, it could be density.
|
||||
*/
|
||||
it('produces internal edge ratio, not graph density, for a tight cluster with external edges', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
// Clique of 4 nodes
|
||||
const clique = addClique(graph, 'c', 'cluster', 4);
|
||||
|
||||
// Two external nodes, each connected to one clique member
|
||||
graph.addNode(makeNode('fn:ext0', 'extFn0', 'Function', '/src/other/ext0.ts'));
|
||||
graph.addNode(makeNode('fn:ext1', 'extFn1', 'Function', '/src/other/ext1.ts'));
|
||||
// Connect ext nodes to each other so they form their own community (size >= 2)
|
||||
graph.addRelationship(makeRel('rel:ext_link', 'fn:ext0', 'fn:ext1'));
|
||||
// Boundary edges from clique to external
|
||||
graph.addRelationship(makeRel('rel:boundary0', clique[0], 'fn:ext0'));
|
||||
graph.addRelationship(makeRel('rel:boundary1', clique[1], 'fn:ext1'));
|
||||
|
||||
const result = await processCommunities(graph);
|
||||
|
||||
// Find the community containing the clique nodes
|
||||
const cliqueMemberSet = new Set(clique);
|
||||
const membershipMap = new Map<string, string>();
|
||||
for (const m of result.memberships) {
|
||||
membershipMap.set(m.nodeId, m.communityId);
|
||||
}
|
||||
|
||||
// Determine which community the clique nodes belong to
|
||||
const cliqueCommunityId = membershipMap.get(clique[0]);
|
||||
expect(cliqueCommunityId).toBeDefined();
|
||||
|
||||
// All clique nodes should be in the same community
|
||||
for (const nodeId of clique) {
|
||||
expect(membershipMap.get(nodeId)).toBe(cliqueCommunityId);
|
||||
}
|
||||
|
||||
// Find the community node
|
||||
const cliqueCommunity = result.communities.find(c => c.id === cliqueCommunityId);
|
||||
expect(cliqueCommunity).toBeDefined();
|
||||
|
||||
// Key assertion: cohesion should be < 1.0 (edge ratio with boundary edges)
|
||||
// Graph density would be 1.0 since 4 nodes are fully connected internally.
|
||||
// Edge ratio: 12 internal traversals / 14 total traversals = ~0.857
|
||||
expect(cliqueCommunity!.cohesion).toBeLessThan(1.0);
|
||||
expect(cliqueCommunity!.cohesion).toBeCloseTo(12 / 14, 2);
|
||||
});
|
||||
|
||||
/**
|
||||
* A fully isolated clique with no external edges.
|
||||
* Both formulas agree: cohesion should be 1.0 because all edges are internal.
|
||||
*/
|
||||
it('cohesion is 1.0 when community has no external edges', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
// Single isolated clique of 4 — no boundary edges at all
|
||||
addClique(graph, 'iso', 'isolated', 4);
|
||||
|
||||
const result = await processCommunities(graph);
|
||||
|
||||
// Should produce exactly one community (singletons are filtered)
|
||||
expect(result.communities.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The community containing our clique should have cohesion 1.0
|
||||
const community = result.communities.find(c => c.symbolCount >= 4);
|
||||
// If Leiden puts them all in one community (expected for a fully connected graph)
|
||||
if (community) {
|
||||
expect(community.cohesion).toBe(1.0);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Two variants of the same base clique: one with few external edges,
|
||||
* one with many. The variant with more external edges should have lower cohesion.
|
||||
*/
|
||||
it('cohesion decreases as external edge proportion increases', async () => {
|
||||
// --- Variant A: clique with 1 external edge ---
|
||||
const graphA = createKnowledgeGraph();
|
||||
const cliqueA = addClique(graphA, 'a', 'groupA', 4);
|
||||
// One external node pair (to form a valid community)
|
||||
graphA.addNode(makeNode('fn:extA0', 'extA0', 'Function', '/src/extA/e0.ts'));
|
||||
graphA.addNode(makeNode('fn:extA1', 'extA1', 'Function', '/src/extA/e1.ts'));
|
||||
graphA.addRelationship(makeRel('rel:extA_link', 'fn:extA0', 'fn:extA1'));
|
||||
// 1 boundary edge
|
||||
graphA.addRelationship(makeRel('rel:bndA0', cliqueA[0], 'fn:extA0'));
|
||||
|
||||
const resultA = await processCommunities(graphA);
|
||||
const commIdA = resultA.memberships.find(m => m.nodeId === cliqueA[0])?.communityId;
|
||||
const communityA = resultA.communities.find(c => c.id === commIdA);
|
||||
|
||||
// --- Variant B: clique with 4 external edges ---
|
||||
const graphB = createKnowledgeGraph();
|
||||
const cliqueB = addClique(graphB, 'b', 'groupB', 4);
|
||||
// Four external nodes (two pairs)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graphB.addNode(makeNode(`fn:extB${i}`, `extB${i}`, 'Function', `/src/extB/e${i}.ts`));
|
||||
}
|
||||
graphB.addRelationship(makeRel('rel:extB_link0', 'fn:extB0', 'fn:extB1'));
|
||||
graphB.addRelationship(makeRel('rel:extB_link1', 'fn:extB2', 'fn:extB3'));
|
||||
// 4 boundary edges (one per clique node)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graphB.addRelationship(makeRel(`rel:bndB${i}`, cliqueB[i], `fn:extB${i}`));
|
||||
}
|
||||
|
||||
const resultB = await processCommunities(graphB);
|
||||
const commIdB = resultB.memberships.find(m => m.nodeId === cliqueB[0])?.communityId;
|
||||
const communityB = resultB.communities.find(c => c.id === commIdB);
|
||||
|
||||
expect(communityA).toBeDefined();
|
||||
expect(communityB).toBeDefined();
|
||||
|
||||
// More external edges => lower cohesion
|
||||
expect(communityB!.cohesion).toBeLessThan(communityA!.cohesion);
|
||||
});
|
||||
|
||||
/**
|
||||
* Edge case: a community with a single node should return cohesion 1.0.
|
||||
* The code returns early for memberIds.length <= 1.
|
||||
* Leiden skips singletons (communities with < 2 members), so we test this
|
||||
* by building a graph where one node has no edges — it won't appear in a
|
||||
* community at all. Instead, test with 2 connected nodes and verify the
|
||||
* community gets cohesion 1.0 (2 nodes, 1 internal edge, 0 external = 1.0).
|
||||
*/
|
||||
it('two-node community with no external edges returns 1.0', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
graph.addNode(makeNode('fn:pair0', 'pairFn0', 'Function', '/src/pair/f0.ts'));
|
||||
graph.addNode(makeNode('fn:pair1', 'pairFn1', 'Function', '/src/pair/f1.ts'));
|
||||
graph.addRelationship(makeRel('rel:pair', 'fn:pair0', 'fn:pair1'));
|
||||
|
||||
const result = await processCommunities(graph);
|
||||
|
||||
// Should have exactly 1 community with 2 members
|
||||
expect(result.communities).toHaveLength(1);
|
||||
expect(result.communities[0].symbolCount).toBe(2);
|
||||
expect(result.communities[0].cohesion).toBe(1.0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Sanity check: an empty graph should yield no communities.
|
||||
*/
|
||||
it('empty graph returns empty communities', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const result = await processCommunities(graph);
|
||||
|
||||
expect(result.communities).toEqual([]);
|
||||
expect(result.memberships).toEqual([]);
|
||||
expect(result.stats.totalCommunities).toBe(0);
|
||||
expect(result.stats.nodesProcessed).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that the web and backend formulas produce equivalent results
|
||||
* by checking the backend value against a hand-calculated edge-ratio result.
|
||||
*
|
||||
* Topology: 3-node triangle (clique) + 1 external node connected to one vertex.
|
||||
* - Triangle: 3 internal edges
|
||||
* - 1 boundary edge from vertex 0 to external node
|
||||
* - Traversals from triangle members:
|
||||
* vertex0: 2 internal neighbors + 1 external = 3 traversals
|
||||
* vertex1: 2 internal neighbors = 2 traversals
|
||||
* vertex2: 2 internal neighbors = 2 traversals
|
||||
* - Total traversals: 7, internal traversals: 6
|
||||
* - Edge ratio: 6/7 ≈ 0.857
|
||||
*
|
||||
* The external node is a singleton so Leiden won't produce a community for it.
|
||||
* But we need at least 2 external nodes connected to each other for Leiden
|
||||
* to form a second community. Let's add a second external node.
|
||||
* - vertex0 connects to ext0, ext0 connects to ext1
|
||||
* - Triangle traversals:
|
||||
* vertex0: 2 internal + 1 external = 3
|
||||
* vertex1: 2 internal = 2
|
||||
* vertex2: 2 internal = 2
|
||||
* - Total: 7, internal: 6, ratio: 6/7 ≈ 0.857
|
||||
*/
|
||||
it('web and backend formulas produce equivalent edge-ratio results', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
|
||||
// Triangle clique
|
||||
const tri = ['fn:t0', 'fn:t1', 'fn:t2'];
|
||||
graph.addNode(makeNode('fn:t0', 'triFn0', 'Function', '/src/tri/f0.ts'));
|
||||
graph.addNode(makeNode('fn:t1', 'triFn1', 'Function', '/src/tri/f1.ts'));
|
||||
graph.addNode(makeNode('fn:t2', 'triFn2', 'Function', '/src/tri/f2.ts'));
|
||||
graph.addRelationship(makeRel('rel:t01', 'fn:t0', 'fn:t1'));
|
||||
graph.addRelationship(makeRel('rel:t02', 'fn:t0', 'fn:t2'));
|
||||
graph.addRelationship(makeRel('rel:t12', 'fn:t1', 'fn:t2'));
|
||||
|
||||
// External pair
|
||||
graph.addNode(makeNode('fn:ext0', 'extFn0', 'Function', '/src/ext/e0.ts'));
|
||||
graph.addNode(makeNode('fn:ext1', 'extFn1', 'Function', '/src/ext/e1.ts'));
|
||||
graph.addRelationship(makeRel('rel:ext', 'fn:ext0', 'fn:ext1'));
|
||||
|
||||
// Boundary edge: triangle vertex0 -> ext0
|
||||
graph.addRelationship(makeRel('rel:bnd', 'fn:t0', 'fn:ext0'));
|
||||
|
||||
const result = await processCommunities(graph);
|
||||
|
||||
// Find triangle community
|
||||
const triCommId = result.memberships.find(m => m.nodeId === 'fn:t0')?.communityId;
|
||||
expect(triCommId).toBeDefined();
|
||||
|
||||
const triComm = result.communities.find(c => c.id === triCommId);
|
||||
expect(triComm).toBeDefined();
|
||||
|
||||
// Hand-calculated edge ratio: 6 internal traversals / 7 total = 0.8571...
|
||||
const expectedEdgeRatio = 6 / 7;
|
||||
expect(triComm!.cohesion).toBeCloseTo(expectedEdgeRatio, 2);
|
||||
|
||||
// Verify it's NOT graph density (which would be 3 / (3*2/2) = 1.0)
|
||||
expect(triComm!.cohesion).not.toBeCloseTo(1.0, 2);
|
||||
});
|
||||
});
|
||||
712
gitnexus/test/unit/skill-gen.test.ts
Normal file
712
gitnexus/test/unit/skill-gen.test.ts
Normal file
|
|
@ -0,0 +1,712 @@
|
|||
/**
|
||||
* Unit & integration tests for the skill file generator.
|
||||
*
|
||||
* Tests generateSkillFiles() — the only public export from cli/skill-gen.ts.
|
||||
* Validates return values (skill metadata), aggregation logic, edge cases,
|
||||
* and the on-disk SKILL.md files produced.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { generateSkillFiles } from '../../src/cli/skill-gen.js';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import type { GraphNode, GraphRelationship, KnowledgeGraph } from '../../src/core/graph/types.js';
|
||||
import type { CommunityNode, CommunityMembership, CommunityDetectionResult } from '../../src/core/ingestion/community-processor.js';
|
||||
import type { ProcessNode, ProcessDetectionResult } from '../../src/core/ingestion/process-processor.js';
|
||||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||||
|
||||
// ============================================================================
|
||||
// FIXTURE HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/** Create a GraphNode with commonly-needed properties */
|
||||
function makeNode(
|
||||
id: string,
|
||||
name: string,
|
||||
label: GraphNode['label'],
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
isExported: boolean,
|
||||
): GraphNode {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
properties: { name, filePath, startLine, endLine: startLine + 10, isExported },
|
||||
};
|
||||
}
|
||||
|
||||
/** Create a GraphRelationship between two nodes */
|
||||
function makeRel(
|
||||
id: string,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
type: GraphRelationship['type'],
|
||||
): GraphRelationship {
|
||||
return { id, sourceId, targetId, type, confidence: 1.0, reason: '' };
|
||||
}
|
||||
|
||||
/** Create a CommunityNode with default cohesion */
|
||||
function makeCommunity(
|
||||
id: string,
|
||||
label: string,
|
||||
symbolCount: number,
|
||||
cohesion: number = 0.75,
|
||||
): CommunityNode {
|
||||
return { id, label, heuristicLabel: label, cohesion, symbolCount };
|
||||
}
|
||||
|
||||
/** Create a membership record linking a node to a community */
|
||||
function makeMembership(nodeId: string, communityId: string): CommunityMembership {
|
||||
return { nodeId, communityId };
|
||||
}
|
||||
|
||||
/** Create a ProcessNode for testing execution flows */
|
||||
function makeProcess(
|
||||
id: string,
|
||||
label: string,
|
||||
communities: string[],
|
||||
stepCount: number,
|
||||
): ProcessNode {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
heuristicLabel: label,
|
||||
processType: communities.length > 1 ? 'cross_community' : 'intra_community',
|
||||
stepCount,
|
||||
communities,
|
||||
entryPointId: '',
|
||||
terminalId: '',
|
||||
trace: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble a full PipelineResult from individual pieces.
|
||||
* Only graph is required; community and process data default to empty.
|
||||
*/
|
||||
function buildPipelineResult(opts: {
|
||||
graph: KnowledgeGraph;
|
||||
repoPath: string;
|
||||
communities?: CommunityNode[];
|
||||
memberships?: CommunityMembership[];
|
||||
processes?: ProcessNode[];
|
||||
}): PipelineResult {
|
||||
const communityResult: CommunityDetectionResult = {
|
||||
communities: opts.communities ?? [],
|
||||
memberships: opts.memberships ?? [],
|
||||
stats: {
|
||||
totalCommunities: (opts.communities ?? []).length,
|
||||
modularity: 0.5,
|
||||
nodesProcessed: (opts.memberships ?? []).length,
|
||||
},
|
||||
};
|
||||
|
||||
const processResult: ProcessDetectionResult | undefined =
|
||||
opts.processes
|
||||
? {
|
||||
processes: opts.processes,
|
||||
steps: [],
|
||||
stats: {
|
||||
totalProcesses: opts.processes.length,
|
||||
crossCommunityCount: opts.processes.filter(p => p.processType === 'cross_community').length,
|
||||
avgStepCount: opts.processes.length > 0
|
||||
? opts.processes.reduce((s, p) => s + p.stepCount, 0) / opts.processes.length
|
||||
: 0,
|
||||
entryPointsFound: 0,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
graph: opts.graph,
|
||||
repoPath: opts.repoPath,
|
||||
totalFileCount: 0,
|
||||
communityResult,
|
||||
processResult,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TESTS — RETURN VALUES
|
||||
// ============================================================================
|
||||
|
||||
describe('generateSkillFiles — return values', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-skill-test-'));
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
/**
|
||||
* When memberships array is empty, there is nothing to group into skills.
|
||||
* Should return an empty skills array and the expected output path.
|
||||
*/
|
||||
it('returns empty skills when memberships is empty', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph,
|
||||
repoPath: tmpDir,
|
||||
communities: [],
|
||||
memberships: [],
|
||||
}));
|
||||
|
||||
expect(result.skills).toEqual([]);
|
||||
expect(result.outputPath).toBe(path.join(tmpDir, '.claude', 'skills', 'generated'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Communities with fewer than 3 symbols are filtered out.
|
||||
* Three communities each with 2 symbols should all be excluded.
|
||||
*/
|
||||
it('returns empty skills when all communities are below threshold', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
// Add 6 nodes — 2 per community
|
||||
for (let i = 0; i < 6; i++) {
|
||||
graph.addNode(makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/src/f${i}.ts`, 1, false));
|
||||
}
|
||||
|
||||
const communities = [
|
||||
makeCommunity('c1', 'Small1', 2),
|
||||
makeCommunity('c2', 'Small2', 2),
|
||||
makeCommunity('c3', 'Small3', 2),
|
||||
];
|
||||
const memberships = [
|
||||
makeMembership('fn:n0', 'c1'), makeMembership('fn:n1', 'c1'),
|
||||
makeMembership('fn:n2', 'c2'), makeMembership('fn:n3', 'c2'),
|
||||
makeMembership('fn:n4', 'c3'), makeMembership('fn:n5', 'c3'),
|
||||
];
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A single valid community with 5 nodes across 2 files, some exported.
|
||||
* Should return exactly 1 skill with correct metadata.
|
||||
*/
|
||||
it('returns 1 skill for a single valid community', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
graph.addNode(makeNode('fn:a', 'alpha', 'Function', `${tmpDir}/src/auth/login.ts`, 1, true));
|
||||
graph.addNode(makeNode('fn:b', 'beta', 'Function', `${tmpDir}/src/auth/login.ts`, 20, false));
|
||||
graph.addNode(makeNode('fn:c', 'gamma', 'Class', `${tmpDir}/src/auth/session.ts`, 1, true));
|
||||
graph.addNode(makeNode('fn:d', 'delta', 'Function', `${tmpDir}/src/auth/session.ts`, 40, false));
|
||||
graph.addNode(makeNode('fn:e', 'epsilon', 'Function', `${tmpDir}/src/auth/session.ts`, 60, true));
|
||||
|
||||
const communities = [makeCommunity('c1', 'Auth', 5, 0.8)];
|
||||
const memberships = ['fn:a', 'fn:b', 'fn:c', 'fn:d', 'fn:e']
|
||||
.map(id => makeMembership(id, 'c1'));
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0].label).toBe('Auth');
|
||||
expect(result.skills[0].symbolCount).toBe(5);
|
||||
expect(result.skills[0].fileCount).toBe(2);
|
||||
expect(result.skills[0].name).toBe('auth');
|
||||
});
|
||||
|
||||
/**
|
||||
* Two communities with the same heuristicLabel should be aggregated
|
||||
* into one skill with summed symbolCount.
|
||||
*/
|
||||
it('aggregates communities with same label into one skill', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
graph.addNode(makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/src/auth/f${i}.ts`, 1, false));
|
||||
}
|
||||
|
||||
const communities = [
|
||||
makeCommunity('c1', 'Auth', 4, 0.7),
|
||||
makeCommunity('c2', 'Auth', 4, 0.9),
|
||||
];
|
||||
const memberships = [
|
||||
...['fn:n0', 'fn:n1', 'fn:n2', 'fn:n3'].map(id => makeMembership(id, 'c1')),
|
||||
...['fn:n4', 'fn:n5', 'fn:n6', 'fn:n7'].map(id => makeMembership(id, 'c2')),
|
||||
];
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0].label).toBe('Auth');
|
||||
expect(result.skills[0].symbolCount).toBe(8);
|
||||
});
|
||||
|
||||
/**
|
||||
* The generator caps output at 20 skills regardless of how many
|
||||
* communities pass the threshold.
|
||||
*/
|
||||
it('caps skills at 20 even with more valid communities', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const communities: CommunityNode[] = [];
|
||||
const memberships: CommunityMembership[] = [];
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const commId = `c${i}`;
|
||||
communities.push(makeCommunity(commId, `Area${i}`, 4));
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const nodeId = `fn:c${i}_n${j}`;
|
||||
graph.addNode(makeNode(nodeId, `func_${i}_${j}`, 'Function', `${tmpDir}/src/area${i}/f${j}.ts`, 1, false));
|
||||
memberships.push(makeMembership(nodeId, commId));
|
||||
}
|
||||
}
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toHaveLength(20);
|
||||
});
|
||||
|
||||
/**
|
||||
* Skills should be sorted by symbolCount descending so the most
|
||||
* significant community appears first.
|
||||
*/
|
||||
it('sorts skills by symbol count descending', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
const sizes = [10, 5, 3];
|
||||
const communities: CommunityNode[] = [];
|
||||
const memberships: CommunityMembership[] = [];
|
||||
|
||||
for (let ci = 0; ci < 3; ci++) {
|
||||
const commId = `c${ci}`;
|
||||
communities.push(makeCommunity(commId, `Area${ci}`, sizes[ci]));
|
||||
for (let ni = 0; ni < sizes[ci]; ni++) {
|
||||
const nodeId = `fn:c${ci}_n${ni}`;
|
||||
graph.addNode(makeNode(nodeId, `func_${ci}_${ni}`, 'Function', `${tmpDir}/src/area${ci}/f${ni}.ts`, 1, false));
|
||||
memberships.push(makeMembership(nodeId, commId));
|
||||
}
|
||||
}
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toHaveLength(3);
|
||||
expect(result.skills[0].symbolCount).toBe(10);
|
||||
expect(result.skills[1].symbolCount).toBe(5);
|
||||
expect(result.skills[2].symbolCount).toBe(3);
|
||||
});
|
||||
|
||||
/**
|
||||
* When the communities array is empty but memberships exist with nodes
|
||||
* in an "auth/" folder, the fallback builder should derive a label from
|
||||
* the most common parent directory.
|
||||
*/
|
||||
it('uses fallback builder when communities array is empty', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(`fn:n${i}`, `authFunc${i}`, 'Function', `${tmpDir}/src/auth/file${i}.ts`, 1, true));
|
||||
}
|
||||
|
||||
const memberships = [0, 1, 2, 3].map(i => makeMembership(`fn:n${i}`, 'comm_0'));
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities: [], memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0].label).toBe('Auth');
|
||||
});
|
||||
|
||||
/**
|
||||
* When processResult is undefined, the generator should still work
|
||||
* without crashing — it simply has no execution flows.
|
||||
*/
|
||||
it('does not crash when processResult is undefined', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(`fn:n${i}`, `func${i}`, 'Function', `${tmpDir}/src/core/f${i}.ts`, 1, false));
|
||||
}
|
||||
|
||||
const communities = [makeCommunity('c1', 'Core', 4)];
|
||||
const memberships = [0, 1, 2, 3].map(i => makeMembership(`fn:n${i}`, 'c1'));
|
||||
|
||||
const pipeline: PipelineResult = {
|
||||
graph,
|
||||
repoPath: tmpDir,
|
||||
totalFileCount: 0,
|
||||
communityResult: {
|
||||
communities,
|
||||
memberships,
|
||||
stats: { totalCommunities: 1, modularity: 0.5, nodesProcessed: 4 },
|
||||
},
|
||||
processResult: undefined,
|
||||
};
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', pipeline);
|
||||
expect(result.skills).toHaveLength(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* Memberships that reference node IDs not present in the graph
|
||||
* should be silently skipped without crashing.
|
||||
*/
|
||||
it('does not crash when memberships reference missing nodes', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
// Only add 2 real nodes but membership references 4
|
||||
graph.addNode(makeNode('fn:real1', 'real1', 'Function', `${tmpDir}/src/mod/a.ts`, 1, false));
|
||||
graph.addNode(makeNode('fn:real2', 'real2', 'Function', `${tmpDir}/src/mod/b.ts`, 1, false));
|
||||
|
||||
const communities = [makeCommunity('c1', 'Mod', 4)];
|
||||
const memberships = [
|
||||
makeMembership('fn:real1', 'c1'),
|
||||
makeMembership('fn:real2', 'c1'),
|
||||
makeMembership('fn:ghost1', 'c1'),
|
||||
makeMembership('fn:ghost2', 'c1'),
|
||||
];
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
// Community has symbolCount=4 which passes threshold, but only 2 real nodes resolve
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0].fileCount).toBe(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* When the same nodeId appears in two raw community IDs that get
|
||||
* aggregated into the same label, it should not be double-counted
|
||||
* in the file output.
|
||||
*/
|
||||
it('does not double-count nodes shared across aggregated communities', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
graph.addNode(makeNode('fn:shared', 'shared', 'Function', `${tmpDir}/src/data/shared.ts`, 1, true));
|
||||
graph.addNode(makeNode('fn:a', 'a', 'Function', `${tmpDir}/src/data/a.ts`, 1, false));
|
||||
graph.addNode(makeNode('fn:b', 'b', 'Function', `${tmpDir}/src/data/b.ts`, 1, false));
|
||||
|
||||
// Two raw communities both named "Data", both containing fn:shared
|
||||
const communities = [
|
||||
makeCommunity('c1', 'Data', 2, 0.8),
|
||||
makeCommunity('c2', 'Data', 2, 0.7),
|
||||
];
|
||||
const memberships = [
|
||||
makeMembership('fn:shared', 'c1'),
|
||||
makeMembership('fn:a', 'c1'),
|
||||
makeMembership('fn:shared', 'c2'),
|
||||
makeMembership('fn:b', 'c2'),
|
||||
];
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toHaveLength(1);
|
||||
// fileCount should be 3 (shared.ts, a.ts, b.ts) — not 4
|
||||
expect(result.skills[0].fileCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// TESTS — FILE OUTPUT
|
||||
// ============================================================================
|
||||
|
||||
describe('generateSkillFiles — file output', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-skill-out-'));
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
try {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
} catch { /* best-effort */ }
|
||||
});
|
||||
|
||||
/** Helper: create a standard 2-community setup for file-output tests */
|
||||
function twoCommSetup() {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(`fn:a${i}`, `alphaFn${i}`, 'Function', `${tmpDir}/src/alpha/f${i}.ts`, i * 10 + 1, i < 2));
|
||||
}
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(`fn:b${i}`, `betaFn${i}`, 'Function', `${tmpDir}/src/beta/f${i}.ts`, i * 10 + 1, i < 2));
|
||||
}
|
||||
|
||||
const communities = [
|
||||
makeCommunity('cA', 'Alpha', 4, 0.85),
|
||||
makeCommunity('cB', 'Beta', 4, 0.60),
|
||||
];
|
||||
const memberships = [
|
||||
...[0, 1, 2, 3].map(i => makeMembership(`fn:a${i}`, 'cA')),
|
||||
...[0, 1, 2, 3].map(i => makeMembership(`fn:b${i}`, 'cB')),
|
||||
];
|
||||
|
||||
return { graph, communities, memberships };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that each community produces a directory under generated/
|
||||
* containing a SKILL.md file.
|
||||
*/
|
||||
it('creates generated/{name}/SKILL.md for each community', async () => {
|
||||
const { graph, communities, memberships } = twoCommSetup();
|
||||
|
||||
await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
const outputDir = path.join(tmpDir, '.claude', 'skills', 'generated');
|
||||
const alphaSkill = await fs.readFile(path.join(outputDir, 'alpha', 'SKILL.md'), 'utf-8');
|
||||
const betaSkill = await fs.readFile(path.join(outputDir, 'beta', 'SKILL.md'), 'utf-8');
|
||||
expect(alphaSkill.length).toBeGreaterThan(0);
|
||||
expect(betaSkill.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* SKILL.md files should start with YAML frontmatter containing
|
||||
* name and description fields.
|
||||
*/
|
||||
it('starts with frontmatter containing name and description', async () => {
|
||||
const { graph, communities, memberships } = twoCommSetup();
|
||||
|
||||
await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
const content = await fs.readFile(
|
||||
path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(content.startsWith('---')).toBe(true);
|
||||
expect(content).toContain('name:');
|
||||
expect(content).toContain('description:');
|
||||
});
|
||||
|
||||
/**
|
||||
* A community with exported symbols, processes, and cross-community
|
||||
* CALLS edges should have all optional sections rendered.
|
||||
*/
|
||||
it('includes Entry Points, Execution Flows, Connected Areas when data exists', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
// Community A: exported symbols
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(`fn:a${i}`, `alphaFn${i}`, 'Function', `${tmpDir}/src/alpha/f${i}.ts`, 1, true));
|
||||
}
|
||||
// Community B: target of cross-community calls
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(`fn:b${i}`, `betaFn${i}`, 'Function', `${tmpDir}/src/beta/f${i}.ts`, 1, false));
|
||||
}
|
||||
// Cross-community CALLS edge: A -> B
|
||||
graph.addRelationship(makeRel('r1', 'fn:a0', 'fn:b0', 'CALLS'));
|
||||
|
||||
const communities = [
|
||||
makeCommunity('cA', 'Alpha', 4, 0.85),
|
||||
makeCommunity('cB', 'Beta', 4, 0.60),
|
||||
];
|
||||
const memberships = [
|
||||
...[0, 1, 2, 3].map(i => makeMembership(`fn:a${i}`, 'cA')),
|
||||
...[0, 1, 2, 3].map(i => makeMembership(`fn:b${i}`, 'cB')),
|
||||
];
|
||||
|
||||
const processes = [makeProcess('p1', 'AlphaFlow', ['cA'], 5)];
|
||||
|
||||
await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships, processes,
|
||||
}));
|
||||
|
||||
const content = await fs.readFile(
|
||||
path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(content).toContain('## Entry Points');
|
||||
expect(content).toContain('## Execution Flows');
|
||||
expect(content).toContain('## Connected Areas');
|
||||
});
|
||||
|
||||
/**
|
||||
* A community with no exports, no processes, and no cross-community
|
||||
* calls should omit the optional sections entirely.
|
||||
*/
|
||||
it('omits Entry Points, Execution Flows, Connected Areas when absent', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(`fn:n${i}`, `func${i}`, 'Function', `${tmpDir}/src/isolated/f${i}.ts`, 1, false));
|
||||
}
|
||||
|
||||
const communities = [makeCommunity('c1', 'Isolated', 4)];
|
||||
const memberships = [0, 1, 2, 3].map(i => makeMembership(`fn:n${i}`, 'c1'));
|
||||
|
||||
await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships, processes: [],
|
||||
}));
|
||||
|
||||
const content = await fs.readFile(
|
||||
path.join(tmpDir, '.claude', 'skills', 'generated', 'isolated', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(content).not.toContain('## Entry Points');
|
||||
expect(content).not.toContain('## Execution Flows');
|
||||
expect(content).not.toContain('## Connected Areas');
|
||||
});
|
||||
|
||||
/**
|
||||
* Running generateSkillFiles twice with different communities should
|
||||
* clean up the first run's output directories.
|
||||
*/
|
||||
it('cleans up previous run output on re-run', async () => {
|
||||
const graph1 = createKnowledgeGraph();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph1.addNode(makeNode(`fn:x${i}`, `xFunc${i}`, 'Function', `${tmpDir}/src/first/f${i}.ts`, 1, false));
|
||||
}
|
||||
|
||||
// First run
|
||||
await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph: graph1, repoPath: tmpDir,
|
||||
communities: [makeCommunity('c1', 'First', 4)],
|
||||
memberships: [0, 1, 2, 3].map(i => makeMembership(`fn:x${i}`, 'c1')),
|
||||
}));
|
||||
|
||||
const outputDir = path.join(tmpDir, '.claude', 'skills', 'generated');
|
||||
const firstRunDirs = await fs.readdir(outputDir);
|
||||
expect(firstRunDirs).toContain('first');
|
||||
|
||||
// Second run with different community
|
||||
const graph2 = createKnowledgeGraph();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph2.addNode(makeNode(`fn:y${i}`, `yFunc${i}`, 'Function', `${tmpDir}/src/second/f${i}.ts`, 1, false));
|
||||
}
|
||||
|
||||
await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph: graph2, repoPath: tmpDir,
|
||||
communities: [makeCommunity('c2', 'Second', 4)],
|
||||
memberships: [0, 1, 2, 3].map(i => makeMembership(`fn:y${i}`, 'c2')),
|
||||
}));
|
||||
|
||||
const secondRunDirs = await fs.readdir(outputDir);
|
||||
expect(secondRunDirs).toContain('second');
|
||||
expect(secondRunDirs).not.toContain('first');
|
||||
});
|
||||
|
||||
/**
|
||||
* The rendered SKILL.md should contain a stats line matching the
|
||||
* community's symbol count, file count, and cohesion percentage.
|
||||
*/
|
||||
it('contains stats line with correct symbol count, file count, cohesion', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
graph.addNode(makeNode(`fn:s${i}`, `statsFn${i}`, 'Function', `${tmpDir}/src/stats/f${i}.ts`, 1, false));
|
||||
}
|
||||
|
||||
const communities = [makeCommunity('c1', 'Stats', 5, 0.82)];
|
||||
const memberships = [0, 1, 2, 3, 4].map(i => makeMembership(`fn:s${i}`, 'c1'));
|
||||
|
||||
await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
const content = await fs.readFile(
|
||||
path.join(tmpDir, '.claude', 'skills', 'generated', 'stats', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
expect(content).toContain('5 symbols | 5 files | Cohesion: 82%');
|
||||
});
|
||||
|
||||
/**
|
||||
* Labels with special characters (like "C++ Core") should be converted
|
||||
* to a valid kebab-case directory name without crashing.
|
||||
*/
|
||||
it('handles special characters in label for directory name', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(`fn:cpp${i}`, `cppFunc${i}`, 'Function', `${tmpDir}/src/cpp/f${i}.ts`, 1, false));
|
||||
}
|
||||
|
||||
const communities = [makeCommunity('c1', 'C++ Core', 4)];
|
||||
const memberships = [0, 1, 2, 3].map(i => makeMembership(`fn:cpp${i}`, 'c1'));
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toHaveLength(1);
|
||||
// The kebab name should only contain lowercase alphanumerics and dashes
|
||||
expect(result.skills[0].name).toMatch(/^[a-z0-9-]+$/);
|
||||
|
||||
const skillPath = path.join(tmpDir, '.claude', 'skills', 'generated', result.skills[0].name, 'SKILL.md');
|
||||
const content = await fs.readFile(skillPath, 'utf-8');
|
||||
expect(content.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Nodes with no filePath should not crash the generator.
|
||||
* The skill should still be generated with fileCount 0.
|
||||
*/
|
||||
it('handles nodes with no filePath', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(`fn:nf${i}`, `nofileFunc${i}`, 'Function', '', 0, false));
|
||||
}
|
||||
|
||||
const communities = [makeCommunity('c1', 'NoFile', 4)];
|
||||
const memberships = [0, 1, 2, 3].map(i => makeMembership(`fn:nf${i}`, 'c1'));
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0].fileCount).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Node filePaths containing Windows-style backslashes should be
|
||||
* normalized to forward slashes in the Key Files table (which uses
|
||||
* toRelativePath). The Key Symbols table renders raw filePath as-is,
|
||||
* so we only check the Key Files section for normalization.
|
||||
*/
|
||||
it('normalizes Windows backslash paths in Key Files output', async () => {
|
||||
const graph = createKnowledgeGraph();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
graph.addNode(makeNode(
|
||||
`fn:w${i}`, `winFunc${i}`, 'Function',
|
||||
`${tmpDir}\\src\\win\\f${i}.ts`, 1, false,
|
||||
));
|
||||
}
|
||||
|
||||
const communities = [makeCommunity('c1', 'Win', 4)];
|
||||
const memberships = [0, 1, 2, 3].map(i => makeMembership(`fn:w${i}`, 'c1'));
|
||||
|
||||
const result = await generateSkillFiles(tmpDir, 'TestProject', buildPipelineResult({
|
||||
graph, repoPath: tmpDir, communities, memberships,
|
||||
}));
|
||||
|
||||
expect(result.skills).toHaveLength(1);
|
||||
|
||||
const content = await fs.readFile(
|
||||
path.join(tmpDir, '.claude', 'skills', 'generated', 'win', 'SKILL.md'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
// Extract the Key Files section between "## Key Files" and the next "##"
|
||||
const keyFilesMatch = content.match(/## Key Files\n([\s\S]*?)(?=\n##)/);
|
||||
expect(keyFilesMatch).not.toBeNull();
|
||||
const keyFilesSection = keyFilesMatch![1];
|
||||
// Key Files section should use forward slashes only
|
||||
expect(keyFilesSection).not.toMatch(/\\/);
|
||||
// Verify it actually has file paths
|
||||
expect(keyFilesSection).toContain('src/win/f0.ts');
|
||||
});
|
||||
});
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "GitnexusV2",
|
||||
"name": "GitNexus",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
|
|
|
|||
0
skills.mdm
Normal file
0
skills.mdm
Normal file
Loading…
Add table
Reference in a new issue