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:
Zander Raycraft 2026-03-13 03:29:13 -05:00 committed by GitHub
parent 74c0e462c3
commit 03bfa3c4d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 4249 additions and 33 deletions

View file

@ -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

View file

@ -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
View file

@ -48,6 +48,9 @@ coverage/
# Claude Code worktrees
.claude/worktrees/
# Claude code skills
.claude/skills/generated/
# Assets (screenshots, images)
assets/

View file

@ -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 -->

View file

@ -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 -->

View file

@ -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

View file

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

View file

@ -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.)

View file

@ -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.

View file

@ -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')

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

File diff suppressed because it is too large Load diff

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

View 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
View file

@ -1,5 +1,5 @@
{
"name": "GitnexusV2",
"name": "GitNexus",
"lockfileVersion": 3,
"requires": true,
"packages": {}

0
skills.mdm Normal file
View file