fix(cli): flatten local Claude skill generation

Install repo-local bundled skills directly under .claude/skills and generate community skills as gitnexus-generated-* so Claude can discover them without nested directories.

Update AI context references and documentation to the new flat layout, and tighten managed-block replacement so marker mentions in prose do not corrupt CLAUDE.md during analyze.

test(cli): fix skill generation assertions

Correct the remaining generated-skill path assertion in skill-gen unit tests and repair the integration test comment that broke parsing after switching to gitnexus-generated-* directories.

fix(cli): clean legacy Claude skill namespaces

Remove the legacy generated skill subtree during repo-local skill installation so stale nested skills do not survive plain analyze runs.

Make the managed AI-context block matcher line-bound and CRLF-safe, and cover the legacy cleanup path with a unit test regression.

fix(cli): clear stale generated skills on empty reruns

Move generated skill cleanup ahead of early returns in skill generation so reruns that produce no skills still remove stale gitnexus-generated-* directories.

Add a regression test covering a zero-skill rerun after a prior successful generation.

test(cli): restore C# skills-e2e skip behavior

Treat the absence of gitnexus-generated-* directories as the same skip condition the old nested generated/ layout used.

This fixes the PR CI regression where bundled skills made .claude/skills exist even when no community skills were generated.
This commit is contained in:
Linus Beckhaus 2026-03-30 22:01:47 +02:00
parent c72890d59d
commit 1adee181df
9 changed files with 219 additions and 88 deletions

View file

@ -48,7 +48,7 @@ Generic “core standards” playbooks are often long and stack-specific. For th
- **This repository:** **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**.
- **Cursor:** `.cursor/index.mdc` (always-on rules); optional `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` is deprecated — see `.cursor/index.mdc`.
- **Optional local files:** `NOTES.md` (short vendor-neutral project snapshot). For handoffs, keep notes local (e.g., a scratch file outside the repo) rather than committing `HANDOFF.md`.
- **GitNexus:** skills under `.claude/skills/gitnexus/`; machine-oriented rules in the `gitnexus:start``gitnexus:end` block below.
- **GitNexus:** skills under `.claude/skills/`; machine-oriented rules in the `gitnexus:start``gitnexus:end` block below.
## Changelog
@ -153,12 +153,12 @@ To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.
| 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` |
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

View file

@ -35,7 +35,7 @@ If always-on instructions grow, load deep conventions via conditional reads (e.g
## Reference Documentation
- **This repository:** [AGENTS.md](AGENTS.md) (Cursor + monorepo notes), [ARCHITECTURE.md](ARCHITECTURE.md), [CONTRIBUTING.md](CONTRIBUTING.md), [GUARDRAILS.md](GUARDRAILS.md).
- **GitNexus:** `.claude/skills/gitnexus/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start``gitnexus:end`). See **GitNexus rules** below.
- **GitNexus:** `.claude/skills/`; MCP and indexed-repo rules live only in [AGENTS.md](AGENTS.md) (`gitnexus:start``gitnexus:end`). See **GitNexus rules** below.
## Changelog

View file

@ -252,7 +252,7 @@ gitnexus wiki --base-url <url> # Wiki with custom LLM API base URL
**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.
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 flat Claude skill directories like `.claude/skills/gitnexus-generated-api/`. 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.
---

View file

@ -9,7 +9,7 @@
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { type GeneratedSkillInfo } from './skill-gen.js';
import { GENERATED_SKILL_DIR_PREFIX, type GeneratedSkillInfo } from './skill-gen.js';
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
@ -30,6 +30,8 @@ export interface AIContextOptions {
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
const GITNEXUS_END_MARKER = '<!-- gitnexus:end -->';
const MANAGED_BLOCK_PATTERN =
/^[ \t]*<!-- gitnexus:start -->[ \t]*\r?\n[\s\S]*?^[ \t]*<!-- gitnexus:end -->[ \t]*(?:\r?\n)?/m;
/**
* Generate the full GitNexus context content.
@ -52,19 +54,19 @@ function generateGitNexusContent(
? generatedSkills
.map(
(s) =>
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/generated/${s.name}/SKILL.md\` |`,
`| Work in the ${s.label} area (${s.symbolCount} symbols) | \`.claude/skills/${GENERATED_SKILL_DIR_PREFIX}${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 : ''}`;
| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus-exploring/SKILL.md\` |
| Blast radius / "What breaks if I change X?" | \`.claude/skills/gitnexus-impact-analysis/SKILL.md\` |
| Trace bugs / "Why is X failing?" | \`.claude/skills/gitnexus-debugging/SKILL.md\` |
| Rename / extract / split / refactor | \`.claude/skills/gitnexus-refactoring/SKILL.md\` |
| Tools, resources, schema reference | \`.claude/skills/gitnexus-guide/SKILL.md\` |
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus-cli/SKILL.md\` |${generatedRows ? '\n' + generatedRows : ''}`;
return `${GITNEXUS_START_MARKER}
# GitNexus Code Intelligence
@ -193,14 +195,10 @@ async function upsertGitNexusSection(
const existingContent = await fs.readFile(filePath, 'utf-8');
// Check if GitNexus section already exists
const startIdx = existingContent.indexOf(GITNEXUS_START_MARKER);
const endIdx = existingContent.indexOf(GITNEXUS_END_MARKER);
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
// Replace existing section
const before = existingContent.substring(0, startIdx);
const after = existingContent.substring(endIdx + GITNEXUS_END_MARKER.length);
const match = MANAGED_BLOCK_PATTERN.exec(existingContent);
if (match && match.index !== undefined) {
const before = existingContent.slice(0, match.index);
const after = existingContent.slice(match.index + match[0].length);
const newContent = before + content + after;
await fs.writeFile(filePath, newContent.trim() + '\n', 'utf-8');
return 'updated';
@ -213,13 +211,17 @@ async function upsertGitNexusSection(
}
/**
* Install GitNexus skills to .claude/skills/gitnexus/
* Install GitNexus skills to .claude/skills/
* Works natively with Claude Code, Cursor, and GitHub Copilot
*/
async function installSkills(repoPath: string): Promise<string[]> {
const skillsDir = path.join(repoPath, '.claude', 'skills', 'gitnexus');
const skillsDir = path.join(repoPath, '.claude', 'skills');
const installedSkills: string[] = [];
await fs.mkdir(skillsDir, { recursive: true });
await fs.rm(path.join(skillsDir, 'gitnexus'), { recursive: true, force: true });
await fs.rm(path.join(skillsDir, 'generated'), { recursive: true, force: true });
// Skill definitions bundled with the package
const skills = [
{
@ -323,10 +325,10 @@ export async function generateAIContextFiles(
createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)');
}
// Install skills to .claude/skills/gitnexus/
// Install skills to .claude/skills/
const installedSkills = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
createdFiles.push(`.claude/skills/ (${installedSkills.length} skills)`);
}
return { files: createdFiles };

View file

@ -308,7 +308,7 @@ async function setupCodex(result: SetupResult): Promise<void> {
/**
* Install GitNexus skills to a target directory.
* Each skill is installed as {targetDir}/gitnexus-{skillName}/SKILL.md
* Each skill is installed as {targetDir}/{skillName}/SKILL.md
* following the Agent Skills standard (Cursor, Claude Code, and Codex).
*
* Supports two source layouts:
@ -383,7 +383,7 @@ async function copyDirRecursive(src: string, dest: string): Promise<void> {
}
/**
* Install global Cursor skills to ~/.cursor/skills/gitnexus/
* Install global Cursor skills to ~/.cursor/skills/
*/
async function installCursorSkills(result: SetupResult): Promise<void> {
const cursorDir = path.join(os.homedir(), '.cursor');
@ -401,7 +401,7 @@ async function installCursorSkills(result: SetupResult): Promise<void> {
}
/**
* Install global OpenCode skills to ~/.config/opencode/skill/gitnexus/
* Install global OpenCode skills to ~/.config/opencode/skill/
*/
async function installOpenCodeSkills(result: SetupResult): Promise<void> {
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
@ -421,7 +421,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
}
/**
* Install global Codex skills to ~/.agents/skills/gitnexus/
* Install global Codex skills to ~/.agents/skills/
*/
async function installCodexSkills(result: SetupResult): Promise<void> {
const codexDir = path.join(os.homedir(), '.codex');

View file

@ -14,6 +14,8 @@ import { CommunityNode, CommunityMembership } from '../core/ingestion/community-
import { ProcessNode } from '../core/ingestion/process-processor.js';
import { KnowledgeGraph } from '../core/graph/types.js';
export const GENERATED_SKILL_DIR_PREFIX = 'gitnexus-generated-';
// ============================================================================
// TYPES
// ============================================================================
@ -68,7 +70,17 @@ export const generateSkillFiles = async (
pipelineResult: PipelineResult,
): Promise<{ skills: GeneratedSkillInfo[]; outputPath: string }> => {
const { communityResult, processResult, graph } = pipelineResult;
const outputDir = path.join(repoPath, '.claude', 'skills', 'generated');
const outputDir = path.join(repoPath, '.claude', 'skills');
// Clear previous generated skill directories while preserving bundled skills.
await fs.mkdir(outputDir, { recursive: true });
await fs.rm(path.join(outputDir, 'generated'), { recursive: true, force: true });
const existingEntries = await fs.readdir(outputDir, { withFileTypes: true });
await Promise.all(
existingEntries
.filter((entry) => entry.isDirectory() && entry.name.startsWith(GENERATED_SKILL_DIR_PREFIX))
.map((entry) => fs.rm(path.join(outputDir, entry.name), { recursive: true, force: true })),
);
if (!communityResult || !communityResult.memberships.length) {
console.log('\n Skills: no communities detected, skipping skill generation');
@ -107,14 +119,6 @@ export const generateSkillFiles = async (
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>();
@ -159,7 +163,7 @@ export const generateSkillFiles = async (
);
// Write file
const skillDir = path.join(outputDir, kebabName);
const skillDir = path.join(outputDir, `${GENERATED_SKILL_DIR_PREFIX}${kebabName}`);
await fs.mkdir(skillDir, { recursive: true });
await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf-8');
@ -176,7 +180,9 @@ export const generateSkillFiles = async (
);
}
console.log(`\n ${skills.length} skills generated \u2192 .claude/skills/generated/`);
console.log(
`\n ${skills.length} skills generated \u2192 .claude/skills/${GENERATED_SKILL_DIR_PREFIX}*/`,
);
return { skills, outputPath: outputDir };
};

View file

@ -80,7 +80,7 @@ function createFixtureRepo(prefix: string, files: Record<string, string>): strin
* Assert standard skill file properties:
* 1. CLI exits 0
* 2. .gitnexus/ exists
* 3. >= minSkills SKILL.md files under .claude/skills/generated/
* 3. >= minSkills SKILL.md files under flat .claude/skills/gitnexus-generated-* directories
* 4. YAML frontmatter valid
* 5. ## Key Files section present
* 6. ## How to Explore section present
@ -109,20 +109,28 @@ function assertSkillFiles(
expect(fs.existsSync(path.join(tmpDir, '.gitnexus'))).toBe(true);
const generatedDir = path.join(tmpDir, '.claude', 'skills', 'generated');
if (!fs.existsSync(generatedDir)) {
const skillsRoot = path.join(tmpDir, '.claude', 'skills');
if (!fs.existsSync(skillsRoot)) {
// Native parser may have crashed in worker or Leiden produced 0 communities.
// The pipeline still succeeds (exit 0) but no skills are generated.
// Skip skill assertions gracefully — this is platform-dependent.
return false;
}
const skillDirs = fs
.readdirSync(generatedDir)
.filter((d) => fs.statSync(path.join(generatedDir, d)).isDirectory());
const skillDirs = fs.readdirSync(skillsRoot).filter((d) => {
if (!d.startsWith('gitnexus-generated-')) return false;
return fs.statSync(path.join(skillsRoot, d)).isDirectory();
});
if (skillDirs.length === 0) {
// Bundled skills may still exist under .claude/skills even when community
// skill generation was skipped, so treat the absence of generated dirs as
// the same skip condition the nested-layout version used.
return false;
}
const skillFiles: string[] = [];
for (const dir of skillDirs) {
const skillPath = path.join(generatedDir, dir, 'SKILL.md');
const skillPath = path.join(skillsRoot, dir, 'SKILL.md');
if (fs.existsSync(skillPath)) {
skillFiles.push(skillPath);
}
@ -146,26 +154,28 @@ function assertSkillFiles(
/**
* Assert CLAUDE.md and AGENTS.md contain generated skill references.
* Automatically detects whether skills were generated by checking for
* the generated/ directory.
* generated community skill directories.
*/
function assertContextFiles(result: ReturnType<typeof runSkillsCli>, tmpDir: string) {
if (result.status === null) return;
const generatedDir = path.join(tmpDir, '.claude', 'skills', 'generated');
const skillsGenerated = fs.existsSync(generatedDir);
const skillsRoot = path.join(tmpDir, '.claude', 'skills');
const skillsGenerated =
fs.existsSync(skillsRoot) &&
fs.readdirSync(skillsRoot).some((entry) => entry.startsWith('gitnexus-generated-'));
const claudePath = path.join(tmpDir, 'CLAUDE.md');
expect(fs.existsSync(claudePath)).toBe(true);
if (skillsGenerated) {
const claudeContent = fs.readFileSync(claudePath, 'utf-8');
expect(claudeContent).toContain('.claude/skills/generated/');
expect(claudeContent).toContain('.claude/skills/gitnexus-generated-');
}
const agentsPath = path.join(tmpDir, 'AGENTS.md');
expect(fs.existsSync(agentsPath)).toBe(true);
if (skillsGenerated) {
const agentsContent = fs.readFileSync(agentsPath, 'utf-8');
expect(agentsContent).toContain('.claude/skills/generated/');
expect(agentsContent).toContain('.claude/skills/gitnexus-generated-');
}
}
@ -2403,17 +2413,21 @@ export function createEntry(level: string, msg: string) {
].join('\n'),
).toBe(0);
const generatedDir = path.join(tmpDir, '.claude', 'skills', 'generated');
expect(fs.existsSync(generatedDir)).toBe(true);
const skillsRoot = path.join(tmpDir, '.claude', 'skills');
expect(fs.existsSync(skillsRoot)).toBe(true);
const skillDirs = fs
.readdirSync(generatedDir)
.filter((d) => fs.statSync(path.join(generatedDir, d)).isDirectory());
.readdirSync(skillsRoot)
.filter(
(d) =>
d.startsWith('gitnexus-generated-') &&
fs.statSync(path.join(skillsRoot, d)).isDirectory(),
);
expect(skillDirs.length).toBeGreaterThanOrEqual(1);
/* All SKILL.md files should still have valid frontmatter */
for (const dir of skillDirs) {
const skillPath = path.join(generatedDir, dir, 'SKILL.md');
const skillPath = path.join(skillsRoot, dir, 'SKILL.md');
expect(fs.existsSync(skillPath)).toBe(true);
const content = fs.readFileSync(skillPath, 'utf-8');
expect(content.startsWith('---')).toBe(true);
@ -2426,6 +2440,6 @@ export function createEntry(level: string, msg: string) {
const claudePath = path.join(tmpDir, 'CLAUDE.md');
expect(fs.existsSync(claudePath)).toBe(true);
const claudeContent = fs.readFileSync(claudePath, 'utf-8');
expect(claudeContent).toContain('.claude/skills/generated/');
expect(claudeContent).toContain('.claude/skills/gitnexus-generated-');
}, 90000);
});

View file

@ -66,18 +66,83 @@ describe('generateAIContextFiles', () => {
expect(starts).toBe(1);
});
it('installs skills files', async () => {
it('replaces only the managed GitNexus block when marker text appears in prose', async () => {
const stats = { nodes: 10 };
const claudeMdPath = path.join(tmpDir, 'CLAUDE.md');
await fs.writeFile(
claudeMdPath,
[
'# CLAUDE',
'',
'GitNexus MCP rules are in the `<!-- gitnexus:start -->` ... `<!-- gitnexus:end -->` block in AGENTS.md.',
'',
'<!-- gitnexus:start -->',
'old managed block',
'<!-- gitnexus:end -->',
'',
].join('\n'),
'utf-8',
);
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
const content = await fs.readFile(claudeMdPath, 'utf-8');
expect(content).toContain(
'GitNexus MCP rules are in the `<!-- gitnexus:start -->` ... `<!-- gitnexus:end -->` block in AGENTS.md.',
);
expect(content).not.toContain('old managed block');
expect((content.match(/gitnexus:start/g) || []).length).toBe(2);
});
it('installs bundled skills as flat Claude skills', async () => {
const stats = { nodes: 10 };
const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
// Should have installed skill files
const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus');
try {
const entries = await fs.readdir(skillsDir, { recursive: true });
expect(entries.length).toBeGreaterThan(0);
} catch {
// Skills dir may not be created if skills source doesn't exist in test context
}
expect(result.files).toContain('.claude/skills/ (6 skills)');
const skillPath = path.join(tmpDir, '.claude', 'skills', 'gitnexus-exploring', 'SKILL.md');
const nestedSkillPath = path.join(
tmpDir,
'.claude',
'skills',
'gitnexus',
'gitnexus-exploring',
'SKILL.md',
);
expect(await fs.readFile(skillPath, 'utf-8')).toContain('# Exploring Codebases with GitNexus');
await expect(fs.access(nestedSkillPath)).rejects.toThrow();
});
it('removes legacy nested skill directories when installing flat Claude skills', async () => {
const stats = { nodes: 10 };
const legacyBundledPath = path.join(
tmpDir,
'.claude',
'skills',
'gitnexus',
'gitnexus-exploring',
'SKILL.md',
);
const legacyGeneratedPath = path.join(
tmpDir,
'.claude',
'skills',
'generated',
'legacy-area',
'SKILL.md',
);
await fs.mkdir(path.dirname(legacyBundledPath), { recursive: true });
await fs.mkdir(path.dirname(legacyGeneratedPath), { recursive: true });
await fs.writeFile(legacyBundledPath, 'legacy bundled', 'utf-8');
await fs.writeFile(legacyGeneratedPath, 'legacy generated', 'utf-8');
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
await expect(fs.access(legacyBundledPath)).rejects.toThrow();
await expect(fs.access(legacyGeneratedPath)).rejects.toThrow();
});
it('preserves manual AGENTS.md and CLAUDE.md edits when skipAgentsMd is enabled', async () => {

View file

@ -174,7 +174,7 @@ describe('generateSkillFiles — return values', () => {
);
expect(result.skills).toEqual([]);
expect(result.outputPath).toBe(path.join(tmpDir, '.claude', 'skills', 'generated'));
expect(result.outputPath).toBe(path.join(tmpDir, '.claude', 'skills'));
});
/**
@ -573,10 +573,10 @@ describe('generateSkillFiles — file output', () => {
}
/**
* Verify that each community produces a directory under generated/
* Verify that each community produces a flat Claude skill directory
* containing a SKILL.md file.
*/
it('creates generated/{name}/SKILL.md for each community', async () => {
it('creates gitnexus-generated-{name}/SKILL.md for each community', async () => {
const { graph, communities, memberships } = twoCommSetup();
await generateSkillFiles(
@ -590,9 +590,15 @@ describe('generateSkillFiles — file output', () => {
}),
);
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');
const outputDir = path.join(tmpDir, '.claude', 'skills');
const alphaSkill = await fs.readFile(
path.join(outputDir, 'gitnexus-generated-alpha', 'SKILL.md'),
'utf-8',
);
const betaSkill = await fs.readFile(
path.join(outputDir, 'gitnexus-generated-beta', 'SKILL.md'),
'utf-8',
);
expect(alphaSkill.length).toBeGreaterThan(0);
expect(betaSkill.length).toBeGreaterThan(0);
});
@ -616,7 +622,7 @@ describe('generateSkillFiles — file output', () => {
);
const content = await fs.readFile(
path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'),
path.join(tmpDir, '.claude', 'skills', 'gitnexus-generated-alpha', 'SKILL.md'),
'utf-8',
);
expect(content.startsWith('---')).toBe(true);
@ -669,7 +675,7 @@ describe('generateSkillFiles — file output', () => {
);
const content = await fs.readFile(
path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'),
path.join(tmpDir, '.claude', 'skills', 'gitnexus-generated-alpha', 'SKILL.md'),
'utf-8',
);
@ -706,7 +712,7 @@ describe('generateSkillFiles — file output', () => {
);
const content = await fs.readFile(
path.join(tmpDir, '.claude', 'skills', 'generated', 'isolated', 'SKILL.md'),
path.join(tmpDir, '.claude', 'skills', 'gitnexus-generated-isolated', 'SKILL.md'),
'utf-8',
);
@ -739,9 +745,9 @@ describe('generateSkillFiles — file output', () => {
}),
);
const outputDir = path.join(tmpDir, '.claude', 'skills', 'generated');
const outputDir = path.join(tmpDir, '.claude', 'skills');
const firstRunDirs = await fs.readdir(outputDir);
expect(firstRunDirs).toContain('first');
expect(firstRunDirs).toContain('gitnexus-generated-first');
// Second run with different community
const graph2 = createKnowledgeGraph();
@ -763,8 +769,47 @@ describe('generateSkillFiles — file output', () => {
);
const secondRunDirs = await fs.readdir(outputDir);
expect(secondRunDirs).toContain('second');
expect(secondRunDirs).not.toContain('first');
expect(secondRunDirs).toContain('gitnexus-generated-second');
expect(secondRunDirs).not.toContain('gitnexus-generated-first');
});
it('cleans up previous generated skills when a rerun produces no skills', 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),
);
}
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');
expect(await fs.readdir(outputDir)).toContain('gitnexus-generated-first');
const emptyGraph = createKnowledgeGraph();
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph: emptyGraph,
repoPath: tmpDir,
communities: [],
memberships: [],
}),
);
expect(result.skills).toEqual([]);
const dirsAfterEmptyRun = await fs.readdir(outputDir);
expect(dirsAfterEmptyRun).not.toContain('gitnexus-generated-first');
});
/**
@ -794,7 +839,7 @@ describe('generateSkillFiles — file output', () => {
);
const content = await fs.readFile(
path.join(tmpDir, '.claude', 'skills', 'generated', 'stats', 'SKILL.md'),
path.join(tmpDir, '.claude', 'skills', 'gitnexus-generated-stats', 'SKILL.md'),
'utf-8',
);
@ -835,8 +880,7 @@ describe('generateSkillFiles — file output', () => {
tmpDir,
'.claude',
'skills',
'generated',
result.skills[0].name,
`gitnexus-generated-${result.skills[0].name}`,
'SKILL.md',
);
const content = await fs.readFile(skillPath, 'utf-8');
@ -902,7 +946,7 @@ describe('generateSkillFiles — file output', () => {
expect(result.skills).toHaveLength(1);
const content = await fs.readFile(
path.join(tmpDir, '.claude', 'skills', 'generated', 'win', 'SKILL.md'),
path.join(tmpDir, '.claude', 'skills', 'gitnexus-generated-win', 'SKILL.md'),
'utf-8',
);