From 1adee181dfb16df98fb08a01f69378ec4f824249 Mon Sep 17 00:00:00 2001 From: Linus Beckhaus Date: Mon, 30 Mar 2026 22:01:47 +0200 Subject: [PATCH] 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. --- AGENTS.md | 14 ++-- CLAUDE.md | 2 +- README.md | 2 +- gitnexus/src/cli/ai-context.ts | 42 +++++----- gitnexus/src/cli/setup.ts | 8 +- gitnexus/src/cli/skill-gen.ts | 28 ++++--- gitnexus/test/integration/skills-e2e.test.ts | 50 +++++++----- gitnexus/test/unit/ai-context.test.ts | 83 +++++++++++++++++--- gitnexus/test/unit/skill-gen.test.ts | 78 ++++++++++++++---- 9 files changed, 219 insertions(+), 88 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7e5ca69f..268d7dc0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | diff --git a/CLAUDE.md b/CLAUDE.md index 51787446e..41c68710c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index ae00065df..692219efa 100644 --- a/README.md +++ b/README.md @@ -252,7 +252,7 @@ gitnexus wiki --base-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. --- diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 81aaa496d..f20b838bc 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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 = ''; const GITNEXUS_END_MARKER = ''; +const MANAGED_BLOCK_PATTERN = + /^[ \t]*[ \t]*\r?\n[\s\S]*?^[ \t]*[ \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 { - 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 }; diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 4e89549ab..00a38a473 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -308,7 +308,7 @@ async function setupCodex(result: SetupResult): Promise { /** * 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 { } /** - * Install global Cursor skills to ~/.cursor/skills/gitnexus/ + * Install global Cursor skills to ~/.cursor/skills/ */ async function installCursorSkills(result: SetupResult): Promise { const cursorDir = path.join(os.homedir(), '.cursor'); @@ -401,7 +401,7 @@ async function installCursorSkills(result: SetupResult): Promise { } /** - * Install global OpenCode skills to ~/.config/opencode/skill/gitnexus/ + * Install global OpenCode skills to ~/.config/opencode/skill/ */ async function installOpenCodeSkills(result: SetupResult): Promise { const opencodeDir = path.join(os.homedir(), '.config', 'opencode'); @@ -421,7 +421,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise { } /** - * Install global Codex skills to ~/.agents/skills/gitnexus/ + * Install global Codex skills to ~/.agents/skills/ */ async function installCodexSkills(result: SetupResult): Promise { const codexDir = path.join(os.homedir(), '.codex'); diff --git a/gitnexus/src/cli/skill-gen.ts b/gitnexus/src/cli/skill-gen.ts index e4e66e85b..da8d04b8c 100644 --- a/gitnexus/src/cli/skill-gen.ts +++ b/gitnexus/src/cli/skill-gen.ts @@ -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(); @@ -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 }; }; diff --git a/gitnexus/test/integration/skills-e2e.test.ts b/gitnexus/test/integration/skills-e2e.test.ts index 45726a4c3..735307c2a 100644 --- a/gitnexus/test/integration/skills-e2e.test.ts +++ b/gitnexus/test/integration/skills-e2e.test.ts @@ -80,7 +80,7 @@ function createFixtureRepo(prefix: string, files: Record): 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, 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); }); diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 0a9f52a68..79ae5f436 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -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 `` ... `` block in AGENTS.md.', + '', + '', + 'old managed block', + '', + '', + ].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 `` ... `` 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 () => { diff --git a/gitnexus/test/unit/skill-gen.test.ts b/gitnexus/test/unit/skill-gen.test.ts index 0da67061c..8276a82c7 100644 --- a/gitnexus/test/unit/skill-gen.test.ts +++ b/gitnexus/test/unit/skill-gen.test.ts @@ -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', );