mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-08 22:22:52 +00:00
Address review findings on PR #2488: - skill-gen.ts: wrap mirror-root mkdir and per-skill mirror writes in try/catch + warn, so a mirror failure (e.g. .agents/skills is a file) no longer aborts canonical community-skill generation or destroys prior output. Mirroring is now a weak side-flow, matching ai-context.ts. - git.ts: exclude .agents/ + .agents/** from isWorkingTreeDirty so a tracked .agents/ dir doesn't permanently defeat the up-to-date fast path. - README + --skip-skills help (en/zh): note skills also mirror to .agents/skills/ when .agents/ exists, and --skip-skills skips both. Tests: +18 covering mirror failure paths (root-is-file, per-skill fail, delete-then-rewrite ordering, namespace-scoped cleanup), dirty-check excludes (real-edit regression, prefix collision, subdir .agents/, non-git/git-missing conservative fallback), gate on file-not-dir, and idempotency. Co-Authored-By: Claude <noreply@anthropic.com>
1291 lines
41 KiB
TypeScript
1291 lines
41 KiB
TypeScript
/**
|
|
* 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'));
|
|
});
|
|
|
|
/**
|
|
* 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('gitnexus-area-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.6),
|
|
];
|
|
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 namespaced directory directly under .claude/skills/
|
|
* containing a SKILL.md file.
|
|
*/
|
|
it('creates {name}/SKILL.md as a direct project skill for each community (#2433)', async () => {
|
|
const { graph, communities, memberships } = twoCommSetup();
|
|
|
|
await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({
|
|
graph,
|
|
repoPath: tmpDir,
|
|
communities,
|
|
memberships,
|
|
}),
|
|
);
|
|
|
|
const outputDir = path.join(tmpDir, '.claude', 'skills');
|
|
const alphaSkill = await fs.readFile(
|
|
path.join(outputDir, 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
const betaSkill = await fs.readFile(
|
|
path.join(outputDir, 'gitnexus-area-beta', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(alphaSkill.length).toBeGreaterThan(0);
|
|
expect(betaSkill.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('uses an owned namespace and removes only prior GitNexus-generated outputs (#2433)', async () => {
|
|
const graph = createKnowledgeGraph();
|
|
for (let i = 0; i < 4; i++) {
|
|
graph.addNode(
|
|
makeNode(`fn:cli${i}`, `cliFn${i}`, 'Function', `${tmpDir}/src/cli/f${i}.ts`, 1, true),
|
|
);
|
|
}
|
|
const skillsRoot = path.join(tmpDir, '.claude', 'skills');
|
|
const standardSkill = path.join(skillsRoot, 'gitnexus-cli', 'SKILL.md');
|
|
const userSkill = path.join(skillsRoot, 'auth', 'SKILL.md');
|
|
const legacyGenerated = path.join(skillsRoot, 'generated', 'old', 'SKILL.md');
|
|
const staleGenerated = path.join(skillsRoot, 'gitnexus-area-old', 'SKILL.md');
|
|
for (const file of [standardSkill, userSkill, legacyGenerated, staleGenerated]) {
|
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
await fs.writeFile(file, file, 'utf-8');
|
|
}
|
|
|
|
const result = await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({
|
|
graph,
|
|
repoPath: tmpDir,
|
|
communities: [makeCommunity('c1', 'Cli', 4)],
|
|
memberships: [0, 1, 2, 3].map((i) => makeMembership(`fn:cli${i}`, 'c1')),
|
|
}),
|
|
);
|
|
|
|
expect(result.skills[0].name).toBe('gitnexus-area-cli');
|
|
const generatedContent = await fs.readFile(
|
|
path.join(skillsRoot, 'gitnexus-area-cli', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(generatedContent).toContain('name: gitnexus-area-cli');
|
|
await expect(fs.readFile(standardSkill, 'utf-8')).resolves.toBe(standardSkill);
|
|
await expect(fs.readFile(userSkill, 'utf-8')).resolves.toBe(userSkill);
|
|
await expect(fs.access(path.join(skillsRoot, 'generated'))).rejects.toThrow();
|
|
await expect(fs.access(path.join(skillsRoot, 'gitnexus-area-old'))).rejects.toThrow();
|
|
});
|
|
|
|
/**
|
|
* When the repo contains an .agents/ directory, generated community skills
|
|
* must be mirrored to .agents/skills/ (flat gitnexus-area-* layout, #2434)
|
|
* so agents that prefer repo-local .agents/skills over the global
|
|
* ~/.agents/skills install serve the up-to-date set. The mirror content must
|
|
* match the .claude copy.
|
|
*/
|
|
it('mirrors generated skills to .agents/skills/ when .agents/ exists', async () => {
|
|
const { graph, communities, memberships } = twoCommSetup();
|
|
await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true });
|
|
|
|
await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({
|
|
graph,
|
|
repoPath: tmpDir,
|
|
communities,
|
|
memberships,
|
|
}),
|
|
);
|
|
|
|
const claudeAlpha = await fs.readFile(
|
|
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
const agentsAlpha = await fs.readFile(
|
|
path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
const agentsBeta = await fs.readFile(
|
|
path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-beta', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(agentsAlpha).toBe(claudeAlpha);
|
|
expect(agentsBeta.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
/**
|
|
* Without an .agents/ opt-in, no .agents/skills/ tree should be created —
|
|
* only the canonical .claude/skills/ copy is written.
|
|
*/
|
|
it('does not mirror generated skills to .agents/ when the directory is absent', async () => {
|
|
const { graph, communities, memberships } = twoCommSetup();
|
|
|
|
await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({
|
|
graph,
|
|
repoPath: tmpDir,
|
|
communities,
|
|
memberships,
|
|
}),
|
|
);
|
|
|
|
// Canonical copy exists, mirror does not.
|
|
const claudeAlpha = await fs.readFile(
|
|
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(claudeAlpha.length).toBeGreaterThan(0);
|
|
await expect(fs.access(path.join(tmpDir, '.agents'))).rejects.toThrow();
|
|
});
|
|
|
|
/**
|
|
* MEDIUM 1 (reviewer repro): when `.agents/skills` exists as a regular file,
|
|
* the mirror root mkdir fails. Mirroring must degrade gracefully (warn +
|
|
* disable) and the canonical community skills under .claude/skills/ must
|
|
* still be written in full — never deleted-then-not-rewritten.
|
|
*/
|
|
it('keeps canonical skills intact when .agents/skills is a file (mirror root mkdir fails)', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
const { graph, communities, memberships } = twoCommSetup();
|
|
// .agents/ exists, but .agents/skills is a file — mkdir will EEXIST.
|
|
await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true });
|
|
await fs.writeFile(path.join(tmpDir, '.agents', 'skills'), 'not a directory');
|
|
|
|
await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }),
|
|
);
|
|
|
|
// Canonical skills are fully present.
|
|
const claudeAlpha = await fs.readFile(
|
|
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
const claudeBeta = await fs.readFile(
|
|
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-beta', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(claudeAlpha.length).toBeGreaterThan(0);
|
|
expect(claudeBeta.length).toBeGreaterThan(0);
|
|
// Mirror was disabled with a warning, not a thrown error.
|
|
expect(logSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
/**
|
|
* MEDIUM 1 per-skill: the mirror root is writable, but an individual skill's
|
|
* mirror write fails. The failure must be warned and contained — other
|
|
* communities' canonical AND mirror writes still succeed.
|
|
*/
|
|
it('isolates a per-skill mirror write failure to that skill (best-effort)', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
const { graph, communities, memberships } = twoCommSetup();
|
|
await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true });
|
|
|
|
// Sabotage only the alpha mirror dir: make it a read-only file so the
|
|
// per-skill mkdir(agentsSkillDir) throws EEXIST (not a dir) and is caught.
|
|
await fs.mkdir(path.join(tmpDir, '.agents', 'skills'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-alpha'),
|
|
'file blocks dir',
|
|
);
|
|
|
|
await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }),
|
|
);
|
|
|
|
// Canonical for both communities is intact.
|
|
await expect(
|
|
fs.readFile(
|
|
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
),
|
|
).resolves.toHaveProperty('length');
|
|
const claudeBeta = await fs.readFile(
|
|
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-beta', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(claudeBeta.length).toBeGreaterThan(0);
|
|
// Beta mirror still written (alpha failure did not abort the loop).
|
|
const agentsBeta = await fs.readFile(
|
|
path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-beta', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(agentsBeta).toBe(claudeBeta);
|
|
expect(logSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
/**
|
|
* MEDIUM 1 delete-then-rewrite ordering: the canonical gitnexus-area-*
|
|
* cleanup runs before the mirror writes. A mirror failure after cleanup
|
|
* must not leave canonical missing — canonical is rewritten regardless.
|
|
*/
|
|
it('rewrites canonical skills after cleanup even when mirroring fails', async () => {
|
|
const { graph, communities, memberships } = twoCommSetup();
|
|
await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true });
|
|
|
|
// First run: write canonical + mirror normally.
|
|
await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }),
|
|
);
|
|
const firstAlpha = await fs.readFile(
|
|
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
|
|
// Second run with mirror broken: .agents/skills becomes a file.
|
|
await fs.rm(path.join(tmpDir, '.agents', 'skills'), { recursive: true, force: true });
|
|
await fs.writeFile(path.join(tmpDir, '.agents', 'skills'), 'now a file');
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
|
|
await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }),
|
|
);
|
|
|
|
// Canonical alpha is still present and content is stable (cleanup deleted
|
|
// the old dir, then canonical rewrote it — not lost).
|
|
const secondAlpha = await fs.readFile(
|
|
path.join(tmpDir, '.claude', 'skills', 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(secondAlpha).toBe(firstAlpha);
|
|
});
|
|
|
|
/**
|
|
* Mirror cleanup is namespace-scoped: only stale gitnexus-area-* mirror
|
|
* dirs are removed; mirrored standard skills and user-authored skills under
|
|
* .agents/skills/ survive a re-run.
|
|
*/
|
|
it('clears only stale gitnexus-area-* mirror dirs, preserving others', async () => {
|
|
const { graph, communities, memberships } = twoCommSetup();
|
|
await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true });
|
|
// Pre-existing non-community content that must survive.
|
|
await fs.mkdir(path.join(tmpDir, '.agents', 'skills', 'gitnexus-cli'), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(tmpDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'),
|
|
'standard',
|
|
);
|
|
await fs.mkdir(path.join(tmpDir, '.agents', 'skills', 'user-author'), { recursive: true });
|
|
await fs.writeFile(path.join(tmpDir, '.agents', 'skills', 'user-author', 'SKILL.md'), 'mine');
|
|
// Stale community mirror from a prior run.
|
|
await fs.mkdir(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-old'), {
|
|
recursive: true,
|
|
});
|
|
await fs.writeFile(
|
|
path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-old', 'SKILL.md'),
|
|
'stale',
|
|
);
|
|
|
|
await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }),
|
|
);
|
|
|
|
// Stale community mirror gone; non-community content preserved.
|
|
await expect(
|
|
fs.access(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-old')),
|
|
).rejects.toThrow();
|
|
expect(
|
|
await fs.readFile(
|
|
path.join(tmpDir, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'),
|
|
'utf-8',
|
|
),
|
|
).toBe('standard');
|
|
expect(
|
|
await fs.readFile(path.join(tmpDir, '.agents', 'skills', 'user-author', 'SKILL.md'), 'utf-8'),
|
|
).toBe('mine');
|
|
// Fresh community mirrors written.
|
|
await expect(
|
|
fs.access(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-alpha', 'SKILL.md')),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
/**
|
|
* Empty edge case: no significant communities + .agents/ present must not
|
|
* write or mirror anything, and must not throw.
|
|
*/
|
|
it('writes nothing when no communities are significant, even with .agents/ present', async () => {
|
|
await fs.mkdir(path.join(tmpDir, '.agents'), { recursive: true });
|
|
const graph = createKnowledgeGraph();
|
|
// 2-symbol community — below the 3-symbol threshold.
|
|
for (let i = 0; i < 2; i++) {
|
|
graph.addNode(makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/f${i}.ts`, 1, false));
|
|
}
|
|
const communities = [makeCommunity('c1', 'Tiny', 2)];
|
|
const memberships = [makeMembership('fn:n0', 'c1'), makeMembership('fn:n1', 'c1')];
|
|
|
|
const result = await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }),
|
|
);
|
|
|
|
expect(result.skills).toEqual([]);
|
|
await expect(
|
|
fs.access(path.join(tmpDir, '.agents', 'skills', 'gitnexus-area-tiny')),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
/**
|
|
* 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', 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(content.startsWith('---')).toBe(true);
|
|
expect(content).toContain('name:');
|
|
expect(content).toContain('description:');
|
|
});
|
|
|
|
/**
|
|
* The "How to Explore" section must reference MCP tools by their registered
|
|
* (unprefixed) names — the server registers `context`/`query`, not
|
|
* `gitnexus_context`/`gitnexus_query`, so the prefixed form points agents at
|
|
* tools that do not exist (#2059).
|
|
*/
|
|
it('references MCP tools by their registered (unprefixed) names (#2059)', 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', 'gitnexus-area-alpha', 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(content).not.toMatch(/gitnexus_(context|query|impact|detect_changes|rename|cypher)/);
|
|
expect(content).toContain('context({name:');
|
|
// #2175: advertise the renamed param, not the legacy "query" key.
|
|
expect(content).toContain('query({search_query:');
|
|
expect(content).not.toContain('query({query:');
|
|
});
|
|
|
|
/**
|
|
* 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.6),
|
|
];
|
|
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', 'gitnexus-area-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', 'gitnexus-area-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');
|
|
const firstRunDirs = await fs.readdir(outputDir);
|
|
expect(firstRunDirs).toContain('gitnexus-area-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('gitnexus-area-second');
|
|
expect(secondRunDirs).not.toContain('gitnexus-area-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', 'gitnexus-area-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', result.skills[0].name, 'SKILL.md');
|
|
const content = await fs.readFile(skillPath, 'utf-8');
|
|
expect(content.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("keeps colliding names within Claude Code's 64-character limit", async () => {
|
|
const graph = createKnowledgeGraph();
|
|
for (let i = 0; i < 8; i++) {
|
|
graph.addNode(
|
|
makeNode(
|
|
`fn:long${i}`,
|
|
`longFunc${i}`,
|
|
'Function',
|
|
`${tmpDir}/src/long/f${i}.ts`,
|
|
1,
|
|
false,
|
|
),
|
|
);
|
|
}
|
|
|
|
const sharedPrefix = 'a'.repeat(60);
|
|
const communities = [
|
|
makeCommunity('c1', `${sharedPrefix}one`, 4),
|
|
makeCommunity('c2', `${sharedPrefix}two`, 4),
|
|
];
|
|
const memberships = [
|
|
...[0, 1, 2, 3].map((i) => makeMembership(`fn:long${i}`, 'c1')),
|
|
...[4, 5, 6, 7].map((i) => makeMembership(`fn:long${i}`, 'c2')),
|
|
];
|
|
|
|
const result = await generateSkillFiles(
|
|
tmpDir,
|
|
'TestProject',
|
|
buildPipelineResult({ graph, repoPath: tmpDir, communities, memberships }),
|
|
);
|
|
|
|
expect(result.skills).toHaveLength(2);
|
|
expect(new Set(result.skills.map((skill) => skill.name)).size).toBe(2);
|
|
for (const skill of result.skills) {
|
|
expect(skill.name.length).toBeLessThanOrEqual(64);
|
|
const content = await fs.readFile(
|
|
path.join(tmpDir, '.claude', 'skills', skill.name, 'SKILL.md'),
|
|
'utf-8',
|
|
);
|
|
expect(content).toContain(`name: ${skill.name}`);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* 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', 'gitnexus-area-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');
|
|
});
|
|
});
|