GitNexus/gitnexus/test/unit/skill-gen.test.ts
Gergő Magyar 9ff7337f1e
fix(mcp): rename query/cypher params so Claude Code can call them (#2186)
* fix(mcp): advertise search_query/statement params for query/cypher tools (#2175)

Claude Code drops a tool-call argument named exactly 'query', making the
query and cypher tools unusable from it. Rename the advertised required
parameters to search_query and statement so the client transmits them.
Handler-side backward-compat for the legacy 'query' key follows in the
next commit.

* fix(mcp): accept search_query/statement with legacy query fallback (#2175)

Resolve the new advertised param names in the backend while still accepting
the legacy 'query' key, so curl/HTTP, other MCP clients, the CLI, the group
path, and the internal executeCypher() all keep working. Alias is normalized
once at the callTool chokepoint (covers group-forward + search alias); query()
and cypher() dual-read defensively. New name wins when both are supplied.
Updates the required-error message and adds dual-accept unit + integration
coverage.

* fix(cli): pass canonical search_query/statement params to query/cypher tools (#2175)

Stop the CLI from depending on the deprecated 'query' alias. No user-facing
change — the positional args are unchanged and the backend accepts both keys.

* fix(mcp): generators advertise search_query in query() examples (#2175)

Update the three doc/example generators (ai-context AGENTS/CLAUDE block,
skill-gen community skills, resources repo hint) so future analyze runs emit
query({search_query: ...}) — the param name Claude Code actually transmits.
Tests assert the new form is present and the legacy query({query: form is
absent (the #2059 generator-test pattern).

* docs(mcp): advertise search_query/statement in skill & guidance examples (#2175)

Sync the committed agent-facing docs to the renamed params so a Claude Code
agent following them emits the transmittable key: AGENTS.md/CLAUDE.md gitnexus
block, the canonical gitnexus/skills/* source and its installed/plugin/cursor
mirrors, and the README examples. Scoped rewrite of the two call prefixes only
(query({query: -> search_query, cypher({query: -> statement).

* style(mcp): prettier line-wrap for #2175 alias-resolution edits

* fix(review): uniform search_query precedence + cypher empty guard (#2175)

Code-review findings (correctness/adversarial/api-contract/maintainability
consensus):
- Group-mode query inverted the 'new name wins' rule: the callTool chokepoint
  backfilled params.query only when empty and the @group-forward read
  params.query directly, so a both-keys (or whitespace-legacy) group call let
  the legacy value win — unlike the local path. Replace the hidden param
  mutation with a self-contained 'search_query ?? query' resolve at the
  group-forward; precedence is now uniformly new-wins at every consumer site.
- cypher() now returns the same friendly required-param error as query() when
  neither statement nor query is supplied, instead of a raw DB prepare error.
- Document the legacy alias as permanent (third-party clients may send query=).
Adds group-forward alias tests (both-keys + legacy-only), empty/whitespace
search_query, the search-alias path, and the cypher empty-statement guard.

* fix(review): non-string alias safety + drop stale chokepoint comment (#2175)

Tri-review findings (correctness/adversarial/security + maintainability):
- Non-string statement/search_query/query (the MCP envelope is not
  schema-validated) hit .trim() and threw TypeError to the server boundary
  instead of a friendly required-param error. Introduce resolveAliasString()
  (new name wins; non-string -> undefined) used by query(), cypher(), and the
  group-forward, so all three return the structured error. Empirically verified
  (123 ?? '' -> 123, (123).trim() throws) — this overrides a critic refutation
  that mis-read ?? as a string coercion.
- Remove the stale query() comment claiming alias resolution happens at a
  callTool chokepoint; that mutation was removed earlier in this PR — each site
  resolves the alias itself.
- Document GroupToolPort.query's intentionally-narrower required type vs the
  wider LocalBackend impl.
Adds non-string and empty-new-key precedence tests.

* fix(mcp): alias falls back to legacy value when new key is blank (#2175)

PR #2186 review finding: resolveAliasString used `canonical ?? legacy`
(nullish), so an explicitly empty/whitespace new-name value (e.g.
{search_query:'', query:'real'}) won and was rejected — discarding a valid
legacy value, contradicting the 'new name wins when both supplied' intent.
Resolve to the first NON-BLANK string instead (new preferred when it carries
a real value, else legacy). Covers query(), cypher(), and the group-forward
(all route through the helper); non-string still resolves to a friendly error.
Flips the presence-based test and adds whitespace/cypher/group fallback cases.

* fix(mcp): drop legacy "query" mention from query/cypher schema descriptions (#2175)

PR #2186 review finding: the search_query/statement inputSchema descriptions
named the legacy "query" key — the exact arg Claude Code drops — and
description text is read by an LLM choosing arguments, weakly nudging it to
send "query". Trim the descriptions to their clean form and move the
legacy-alias note to a code comment next to the schema (preserved for
maintainers / non-CC clients). properties/required unchanged (no `query`).
2026-06-13 10:24:16 +01:00

949 lines
28 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', 'generated'));
});
/**
* Communities with fewer than 3 symbols are filtered out.
* Three communities each with 2 symbols should all be excluded.
*/
it('returns empty skills when all communities are below threshold', async () => {
const graph = createKnowledgeGraph();
// Add 6 nodes — 2 per community
for (let i = 0; i < 6; i++) {
graph.addNode(makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/src/f${i}.ts`, 1, false));
}
const communities = [
makeCommunity('c1', 'Small1', 2),
makeCommunity('c2', 'Small2', 2),
makeCommunity('c3', 'Small3', 2),
];
const memberships = [
makeMembership('fn:n0', 'c1'),
makeMembership('fn:n1', 'c1'),
makeMembership('fn:n2', 'c2'),
makeMembership('fn:n3', 'c2'),
makeMembership('fn:n4', 'c3'),
makeMembership('fn:n5', 'c3'),
];
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
expect(result.skills).toEqual([]);
});
/**
* A single valid community with 5 nodes across 2 files, some exported.
* Should return exactly 1 skill with correct metadata.
*/
it('returns 1 skill for a single valid community', async () => {
const graph = createKnowledgeGraph();
graph.addNode(makeNode('fn:a', 'alpha', 'Function', `${tmpDir}/src/auth/login.ts`, 1, true));
graph.addNode(makeNode('fn:b', 'beta', 'Function', `${tmpDir}/src/auth/login.ts`, 20, false));
graph.addNode(makeNode('fn:c', 'gamma', 'Class', `${tmpDir}/src/auth/session.ts`, 1, true));
graph.addNode(
makeNode('fn:d', 'delta', 'Function', `${tmpDir}/src/auth/session.ts`, 40, false),
);
graph.addNode(
makeNode('fn:e', 'epsilon', 'Function', `${tmpDir}/src/auth/session.ts`, 60, true),
);
const communities = [makeCommunity('c1', 'Auth', 5, 0.8)];
const memberships = ['fn:a', 'fn:b', 'fn:c', 'fn:d', 'fn:e'].map((id) =>
makeMembership(id, 'c1'),
);
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
expect(result.skills).toHaveLength(1);
expect(result.skills[0].label).toBe('Auth');
expect(result.skills[0].symbolCount).toBe(5);
expect(result.skills[0].fileCount).toBe(2);
expect(result.skills[0].name).toBe('auth');
});
/**
* Two communities with the same heuristicLabel should be aggregated
* into one skill with summed symbolCount.
*/
it('aggregates communities with same label into one skill', async () => {
const graph = createKnowledgeGraph();
for (let i = 0; i < 8; i++) {
graph.addNode(
makeNode(`fn:n${i}`, `n${i}`, 'Function', `${tmpDir}/src/auth/f${i}.ts`, 1, false),
);
}
const communities = [makeCommunity('c1', 'Auth', 4, 0.7), makeCommunity('c2', 'Auth', 4, 0.9)];
const memberships = [
...['fn:n0', 'fn:n1', 'fn:n2', 'fn:n3'].map((id) => makeMembership(id, 'c1')),
...['fn:n4', 'fn:n5', 'fn:n6', 'fn:n7'].map((id) => makeMembership(id, 'c2')),
];
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
expect(result.skills).toHaveLength(1);
expect(result.skills[0].label).toBe('Auth');
expect(result.skills[0].symbolCount).toBe(8);
});
/**
* The generator caps output at 20 skills regardless of how many
* communities pass the threshold.
*/
it('caps skills at 20 even with more valid communities', async () => {
const graph = createKnowledgeGraph();
const communities: CommunityNode[] = [];
const memberships: CommunityMembership[] = [];
for (let i = 0; i < 25; i++) {
const commId = `c${i}`;
communities.push(makeCommunity(commId, `Area${i}`, 4));
for (let j = 0; j < 4; j++) {
const nodeId = `fn:c${i}_n${j}`;
graph.addNode(
makeNode(
nodeId,
`func_${i}_${j}`,
'Function',
`${tmpDir}/src/area${i}/f${j}.ts`,
1,
false,
),
);
memberships.push(makeMembership(nodeId, commId));
}
}
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
expect(result.skills).toHaveLength(20);
});
/**
* Skills should be sorted by symbolCount descending so the most
* significant community appears first.
*/
it('sorts skills by symbol count descending', async () => {
const graph = createKnowledgeGraph();
const sizes = [10, 5, 3];
const communities: CommunityNode[] = [];
const memberships: CommunityMembership[] = [];
for (let ci = 0; ci < 3; ci++) {
const commId = `c${ci}`;
communities.push(makeCommunity(commId, `Area${ci}`, sizes[ci]));
for (let ni = 0; ni < sizes[ci]; ni++) {
const nodeId = `fn:c${ci}_n${ni}`;
graph.addNode(
makeNode(
nodeId,
`func_${ci}_${ni}`,
'Function',
`${tmpDir}/src/area${ci}/f${ni}.ts`,
1,
false,
),
);
memberships.push(makeMembership(nodeId, commId));
}
}
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
expect(result.skills).toHaveLength(3);
expect(result.skills[0].symbolCount).toBe(10);
expect(result.skills[1].symbolCount).toBe(5);
expect(result.skills[2].symbolCount).toBe(3);
});
/**
* When the communities array is empty but memberships exist with nodes
* in an "auth/" folder, the fallback builder should derive a label from
* the most common parent directory.
*/
it('uses fallback builder when communities array is empty', async () => {
const graph = createKnowledgeGraph();
for (let i = 0; i < 4; i++) {
graph.addNode(
makeNode(`fn:n${i}`, `authFunc${i}`, 'Function', `${tmpDir}/src/auth/file${i}.ts`, 1, true),
);
}
const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:n${i}`, 'comm_0'));
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities: [],
memberships,
}),
);
expect(result.skills).toHaveLength(1);
expect(result.skills[0].label).toBe('Auth');
});
/**
* When processResult is undefined, the generator should still work
* without crashing — it simply has no execution flows.
*/
it('does not crash when processResult is undefined', async () => {
const graph = createKnowledgeGraph();
for (let i = 0; i < 4; i++) {
graph.addNode(
makeNode(`fn:n${i}`, `func${i}`, 'Function', `${tmpDir}/src/core/f${i}.ts`, 1, false),
);
}
const communities = [makeCommunity('c1', 'Core', 4)];
const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:n${i}`, 'c1'));
const pipeline: PipelineResult = {
graph,
repoPath: tmpDir,
totalFileCount: 0,
communityResult: {
communities,
memberships,
stats: { totalCommunities: 1, modularity: 0.5, nodesProcessed: 4 },
},
processResult: undefined,
};
const result = await generateSkillFiles(tmpDir, 'TestProject', pipeline);
expect(result.skills).toHaveLength(1);
});
/**
* Memberships that reference node IDs not present in the graph
* should be silently skipped without crashing.
*/
it('does not crash when memberships reference missing nodes', async () => {
const graph = createKnowledgeGraph();
// Only add 2 real nodes but membership references 4
graph.addNode(makeNode('fn:real1', 'real1', 'Function', `${tmpDir}/src/mod/a.ts`, 1, false));
graph.addNode(makeNode('fn:real2', 'real2', 'Function', `${tmpDir}/src/mod/b.ts`, 1, false));
const communities = [makeCommunity('c1', 'Mod', 4)];
const memberships = [
makeMembership('fn:real1', 'c1'),
makeMembership('fn:real2', 'c1'),
makeMembership('fn:ghost1', 'c1'),
makeMembership('fn:ghost2', 'c1'),
];
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
// Community has symbolCount=4 which passes threshold, but only 2 real nodes resolve
expect(result.skills).toHaveLength(1);
expect(result.skills[0].fileCount).toBe(2);
});
/**
* When the same nodeId appears in two raw community IDs that get
* aggregated into the same label, it should not be double-counted
* in the file output.
*/
it('does not double-count nodes shared across aggregated communities', async () => {
const graph = createKnowledgeGraph();
graph.addNode(
makeNode('fn:shared', 'shared', 'Function', `${tmpDir}/src/data/shared.ts`, 1, true),
);
graph.addNode(makeNode('fn:a', 'a', 'Function', `${tmpDir}/src/data/a.ts`, 1, false));
graph.addNode(makeNode('fn:b', 'b', 'Function', `${tmpDir}/src/data/b.ts`, 1, false));
// Two raw communities both named "Data", both containing fn:shared
const communities = [makeCommunity('c1', 'Data', 2, 0.8), makeCommunity('c2', 'Data', 2, 0.7)];
const memberships = [
makeMembership('fn:shared', 'c1'),
makeMembership('fn:a', 'c1'),
makeMembership('fn:shared', 'c2'),
makeMembership('fn:b', 'c2'),
];
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
expect(result.skills).toHaveLength(1);
// fileCount should be 3 (shared.ts, a.ts, b.ts) — not 4
expect(result.skills[0].fileCount).toBe(3);
});
});
// ============================================================================
// TESTS — FILE OUTPUT
// ============================================================================
describe('generateSkillFiles — file output', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-skill-out-'));
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
try {
await fs.rm(tmpDir, { recursive: true, force: true });
} catch {
/* best-effort */
}
});
/** Helper: create a standard 2-community setup for file-output tests */
function twoCommSetup() {
const graph = createKnowledgeGraph();
for (let i = 0; i < 4; i++) {
graph.addNode(
makeNode(
`fn:a${i}`,
`alphaFn${i}`,
'Function',
`${tmpDir}/src/alpha/f${i}.ts`,
i * 10 + 1,
i < 2,
),
);
}
for (let i = 0; i < 4; i++) {
graph.addNode(
makeNode(
`fn:b${i}`,
`betaFn${i}`,
'Function',
`${tmpDir}/src/beta/f${i}.ts`,
i * 10 + 1,
i < 2,
),
);
}
const communities = [
makeCommunity('cA', 'Alpha', 4, 0.85),
makeCommunity('cB', 'Beta', 4, 0.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 directory under generated/
* containing a SKILL.md file.
*/
it('creates generated/{name}/SKILL.md for each community', async () => {
const { graph, communities, memberships } = twoCommSetup();
await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
const outputDir = path.join(tmpDir, '.claude', 'skills', 'generated');
const alphaSkill = await fs.readFile(path.join(outputDir, 'alpha', 'SKILL.md'), 'utf-8');
const betaSkill = await fs.readFile(path.join(outputDir, 'beta', 'SKILL.md'), 'utf-8');
expect(alphaSkill.length).toBeGreaterThan(0);
expect(betaSkill.length).toBeGreaterThan(0);
});
/**
* SKILL.md files should start with YAML frontmatter containing
* name and description fields.
*/
it('starts with frontmatter containing name and description', async () => {
const { graph, communities, memberships } = twoCommSetup();
await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
const content = await fs.readFile(
path.join(tmpDir, '.claude', 'skills', 'generated', 'alpha', 'SKILL.md'),
'utf-8',
);
expect(content.startsWith('---')).toBe(true);
expect(content).toContain('name:');
expect(content).toContain('description:');
});
/**
* 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', 'generated', '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', 'generated', 'alpha', 'SKILL.md'),
'utf-8',
);
expect(content).toContain('## Entry Points');
expect(content).toContain('## Execution Flows');
expect(content).toContain('## Connected Areas');
});
/**
* A community with no exports, no processes, and no cross-community
* calls should omit the optional sections entirely.
*/
it('omits Entry Points, Execution Flows, Connected Areas when absent', async () => {
const graph = createKnowledgeGraph();
for (let i = 0; i < 4; i++) {
graph.addNode(
makeNode(`fn:n${i}`, `func${i}`, 'Function', `${tmpDir}/src/isolated/f${i}.ts`, 1, false),
);
}
const communities = [makeCommunity('c1', 'Isolated', 4)];
const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:n${i}`, 'c1'));
await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
processes: [],
}),
);
const content = await fs.readFile(
path.join(tmpDir, '.claude', 'skills', 'generated', 'isolated', 'SKILL.md'),
'utf-8',
);
expect(content).not.toContain('## Entry Points');
expect(content).not.toContain('## Execution Flows');
expect(content).not.toContain('## Connected Areas');
});
/**
* Running generateSkillFiles twice with different communities should
* clean up the first run's output directories.
*/
it('cleans up previous run output on re-run', async () => {
const graph1 = createKnowledgeGraph();
for (let i = 0; i < 4; i++) {
graph1.addNode(
makeNode(`fn:x${i}`, `xFunc${i}`, 'Function', `${tmpDir}/src/first/f${i}.ts`, 1, false),
);
}
// First run
await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph: graph1,
repoPath: tmpDir,
communities: [makeCommunity('c1', 'First', 4)],
memberships: [0, 1, 2, 3].map((i) => makeMembership(`fn:x${i}`, 'c1')),
}),
);
const outputDir = path.join(tmpDir, '.claude', 'skills', 'generated');
const firstRunDirs = await fs.readdir(outputDir);
expect(firstRunDirs).toContain('first');
// Second run with different community
const graph2 = createKnowledgeGraph();
for (let i = 0; i < 4; i++) {
graph2.addNode(
makeNode(`fn:y${i}`, `yFunc${i}`, 'Function', `${tmpDir}/src/second/f${i}.ts`, 1, false),
);
}
await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph: graph2,
repoPath: tmpDir,
communities: [makeCommunity('c2', 'Second', 4)],
memberships: [0, 1, 2, 3].map((i) => makeMembership(`fn:y${i}`, 'c2')),
}),
);
const secondRunDirs = await fs.readdir(outputDir);
expect(secondRunDirs).toContain('second');
expect(secondRunDirs).not.toContain('first');
});
/**
* The rendered SKILL.md should contain a stats line matching the
* community's symbol count, file count, and cohesion percentage.
*/
it('contains stats line with correct symbol count, file count, cohesion', async () => {
const graph = createKnowledgeGraph();
for (let i = 0; i < 5; i++) {
graph.addNode(
makeNode(`fn:s${i}`, `statsFn${i}`, 'Function', `${tmpDir}/src/stats/f${i}.ts`, 1, false),
);
}
const communities = [makeCommunity('c1', 'Stats', 5, 0.82)];
const memberships = [0, 1, 2, 3, 4].map((i) => makeMembership(`fn:s${i}`, 'c1'));
await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
const content = await fs.readFile(
path.join(tmpDir, '.claude', 'skills', 'generated', 'stats', 'SKILL.md'),
'utf-8',
);
expect(content).toContain('5 symbols | 5 files | Cohesion: 82%');
});
/**
* Labels with special characters (like "C++ Core") should be converted
* to a valid kebab-case directory name without crashing.
*/
it('handles special characters in label for directory name', async () => {
const graph = createKnowledgeGraph();
for (let i = 0; i < 4; i++) {
graph.addNode(
makeNode(`fn:cpp${i}`, `cppFunc${i}`, 'Function', `${tmpDir}/src/cpp/f${i}.ts`, 1, false),
);
}
const communities = [makeCommunity('c1', 'C++ Core', 4)];
const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:cpp${i}`, 'c1'));
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
expect(result.skills).toHaveLength(1);
// The kebab name should only contain lowercase alphanumerics and dashes
expect(result.skills[0].name).toMatch(/^[a-z0-9-]+$/);
const skillPath = path.join(
tmpDir,
'.claude',
'skills',
'generated',
result.skills[0].name,
'SKILL.md',
);
const content = await fs.readFile(skillPath, 'utf-8');
expect(content.length).toBeGreaterThan(0);
});
/**
* Nodes with no filePath should not crash the generator.
* The skill should still be generated with fileCount 0.
*/
it('handles nodes with no filePath', async () => {
const graph = createKnowledgeGraph();
for (let i = 0; i < 4; i++) {
graph.addNode(makeNode(`fn:nf${i}`, `nofileFunc${i}`, 'Function', '', 0, false));
}
const communities = [makeCommunity('c1', 'NoFile', 4)];
const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:nf${i}`, 'c1'));
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
expect(result.skills).toHaveLength(1);
expect(result.skills[0].fileCount).toBe(0);
});
/**
* Node filePaths containing Windows-style backslashes should be
* normalized to forward slashes in the Key Files table (which uses
* toRelativePath). The Key Symbols table renders raw filePath as-is,
* so we only check the Key Files section for normalization.
*/
it('normalizes Windows backslash paths in Key Files output', async () => {
const graph = createKnowledgeGraph();
for (let i = 0; i < 4; i++) {
graph.addNode(
makeNode(`fn:w${i}`, `winFunc${i}`, 'Function', `${tmpDir}\\src\\win\\f${i}.ts`, 1, false),
);
}
const communities = [makeCommunity('c1', 'Win', 4)];
const memberships = [0, 1, 2, 3].map((i) => makeMembership(`fn:w${i}`, 'c1'));
const result = await generateSkillFiles(
tmpDir,
'TestProject',
buildPipelineResult({
graph,
repoPath: tmpDir,
communities,
memberships,
}),
);
expect(result.skills).toHaveLength(1);
const content = await fs.readFile(
path.join(tmpDir, '.claude', 'skills', 'generated', 'win', 'SKILL.md'),
'utf-8',
);
// Extract the Key Files section between "## Key Files" and the next "##"
const keyFilesMatch = content.match(/## Key Files\n([\s\S]*?)(?=\n##)/);
expect(keyFilesMatch).not.toBeNull();
const keyFilesSection = keyFilesMatch![1];
// Key Files section should use forward slashes only
expect(keyFilesSection).not.toMatch(/\\/);
// Verify it actually has file paths
expect(keyFilesSection).toContain('src/win/f0.ts');
});
});