mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
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:
parent
c72890d59d
commit
1adee181df
9 changed files with 219 additions and 88 deletions
14
AGENTS.md
14
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` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue