Merge branch 'main' into fix/1492-safe-pretool-augment

This commit is contained in:
Gergő Magyar 2026-05-11 16:40:06 +01:00 committed by GitHub
commit 5ca5663fd9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 512 additions and 25 deletions

View file

@ -28,6 +28,7 @@ interface RepoStats {
export interface AIContextOptions {
skipAgentsMd?: boolean;
noStats?: boolean;
skipSkills?: boolean;
}
const GITNEXUS_START_MARKER = '<!-- gitnexus:start -->';
@ -94,6 +95,7 @@ function generateGitNexusContent(
generatedSkills?: GeneratedSkillInfo[],
groupNames?: string[],
noStats?: boolean,
skipSkills?: boolean,
): string {
const generatedRows =
generatedSkills && generatedSkills.length > 0
@ -105,14 +107,26 @@ function generateGitNexusContent(
.join('\n')
: '';
const skillsTable = `| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | \`.claude/skills/gitnexus/gitnexus-exploring/SKILL.md\` |
// Standard skill rows reference files installed by installSkills(). When
// --skip-skills suppresses that install, these rows must be omitted — else
// AGENTS.md/CLAUDE.md would direct agents to read files that don't exist.
// Community skills (generatedRows) live in .claude/skills/generated/ and
// are independent of --skip-skills, so they remain when present.
const standardSkillsRows = skipSkills
? ''
: `| 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 : ''}`;
| Index, status, clean, wiki CLI commands | \`.claude/skills/gitnexus/gitnexus-cli/SKILL.md\` |`;
const tableBody = [standardSkillsRows, generatedRows].filter(Boolean).join('\n');
const skillsTable = tableBody
? `| Task | Read this skill file |
|------|---------------------|
${tableBody}`
: '';
return `${GITNEXUS_START_MARKER}
# GitNexus Code Intelligence
@ -153,11 +167,15 @@ This repository is listed under GitNexus **group(s): ${groupNames.join(', ')}**
`
: ''
}## CLI
}${
skillsTable
? `## CLI
${skillsTable}
${GITNEXUS_END_MARKER}`;
`
: ''
}${GITNEXUS_END_MARKER}`;
}
/**
@ -319,6 +337,7 @@ export async function generateAIContextFiles(
generatedSkills,
groupNames,
options?.noStats,
options?.skipSkills,
);
const createdFiles: string[] = [];
@ -337,10 +356,14 @@ export async function generateAIContextFiles(
createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)');
}
// Install skills to .claude/skills/gitnexus/
const installedSkills = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
// Install skills to .claude/skills/gitnexus/ (unless --skip-skills)
if (!options?.skipSkills) {
const installedSkills = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
}
} else {
createdFiles.push('.claude/skills/gitnexus/ (skipped via --skip-skills)');
}
return { files: createdFiles };

View file

@ -119,6 +119,10 @@ export interface AnalyzeOptions {
skipAgentsMd?: boolean;
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
noStats?: boolean;
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
skipSkills?: boolean;
/** Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills). */
indexOnly?: boolean;
/** Index the folder even when no .git directory is present. */
skipGit?: boolean;
/**
@ -150,6 +154,24 @@ export interface AnalyzeOptions {
embeddingDevice?: string;
}
/**
* Whether the post-index skill step should run.
*
* The gated block does two things in sequence: (1) generates the community
* skill files from `--skills`, and (2) re-runs `generateAIContextFiles` so
* AGENTS.md/CLAUDE.md can reference the freshly written skills. Both are
* suppressed together `--index-only` drops the entire step, not just the
* community-skill write. Name retained for the test contract; see call site
* in `analyzeCommand` for the AGENTS.md/CLAUDE.md re-generation it also gates.
*
* Kept as a pure helper so the `--index-only --skills` contract is unit-tested
* without booting the full analyze pipeline (#742 review).
*/
export const shouldGenerateCommunitySkillFiles = (
options: Pick<AnalyzeOptions, 'skills' | 'indexOnly'> | undefined,
pipelineResult: unknown,
): boolean => Boolean(options?.skills && pipelineResult && !options?.indexOnly);
export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOptions) => {
if (ensureHeap()) return;
@ -245,6 +267,18 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
console.log('\n GitNexus Analyzer\n');
// `--index-only` is the stronger contract — it suppresses every form of file
// injection, including community skill writes that `--skills` would normally
// produce. Surface the override explicitly so users don't wonder why a
// pipeline re-index ran but no skill files appeared. The pipeline still
// re-runs (see `force: options?.force || options?.skills` below); the warning
// is purely about the dropped post-index write step.
if (options?.indexOnly && options?.skills) {
console.log(
' Note: --index-only overrides --skills; community skill files will not be written.\n',
);
}
let repoPath: string;
if (inputPath) {
repoPath = path.resolve(inputPath);
@ -399,6 +433,9 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// ── Run shared analysis orchestrator ───────────────────────────────
try {
const skipAll = options?.indexOnly;
const skipAgentsMd = skipAll || options?.skipAgentsMd;
const skipSkills = skipAll || options?.skipSkills;
const result = await runFullAnalysis(
repoPath,
{
@ -410,7 +447,8 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
embeddingsNodeLimit,
dropEmbeddings: options?.dropEmbeddings,
skipGit: options?.skipGit,
skipAgentsMd: options?.skipAgentsMd,
skipAgentsMd,
skipSkills,
noStats: options?.noStats,
registryName: options?.name,
// Registry-collision bypass — its own CLI flag, intentionally NOT
@ -456,8 +494,10 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
// a healthy index.
await assertAnalysisFinalized(repoPath);
// Skill generation (CLI-only, uses pipeline result from analysis)
if (options?.skills && result.pipelineResult) {
// Skill generation (CLI-only, uses pipeline result from analysis).
// Gated so `--index-only --skills` skips community skill writes too
// (`shouldGenerateCommunitySkillFiles` — see unit test).
if (shouldGenerateCommunitySkillFiles(options, result.pipelineResult)) {
updateBar(99, 'Generating skill files...');
try {
const { generateSkillFiles } = await import('./skill-gen.js');
@ -497,7 +537,7 @@ export const analyzeCommand = async (inputPath?: string, options?: AnalyzeOption
processes: s.processes,
},
skillResult.skills,
{ skipAgentsMd: options?.skipAgentsMd, noStats: options?.noStats },
{ skipAgentsMd, skipSkills, noStats: options?.noStats },
);
}
} catch {

View file

@ -33,9 +33,20 @@ program
'Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` ' +
'preserves any embeddings already present in the index.',
)
.option('--skills', 'Generate repo-specific skill files from detected communities')
.option(
'--skills',
'Generate repo-specific skill files from detected communities ' +
'(no-op when --index-only is also set).',
)
.option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md')
.option('--no-stats', 'Omit volatile file/symbol counts from AGENTS.md and CLAUDE.md')
.option(
'--skip-skills',
'Skip installing standard GitNexus skill files under .claude/skills/gitnexus/. ' +
'Does not suppress community skills from --skills (those use .claude/skills/generated/). ' +
'Use --index-only to skip all AI-context file injection.',
)
.option('--index-only', 'Pure index mode: skip all file injection (AGENTS.md, CLAUDE.md, skills)')
.option(
'--skip-git',
'Treat the provided path/cwd as the index root and skip parent git-root discovery',

View file

@ -86,6 +86,9 @@ async function findRepoForCwd(cwd: string): Promise<{
export async function augment(pattern: string, cwd?: string): Promise<string> {
if (!pattern || pattern.length < 3) return '';
const patternFirstWord = pattern.trim().replace(/'/g, "''").split(/\s+/)[0];
if (!patternFirstWord || patternFirstWord.length < 2) return '';
const workDir = cwd || process.cwd();
try {
@ -104,9 +107,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
// Step 1: BM25 search (fast, no embeddings)
const { results: bm25Results } = await searchFTSFromLbug(pattern, 10, repoId);
if (bm25Results.length === 0) return '';
const { results: bm25Results, ftsAvailable } = await searchFTSFromLbug(pattern, 10, repoId);
// Step 2: Map BM25 file results to symbols
const symbolMatches: Array<{
@ -124,7 +125,7 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
repoId,
`
MATCH (n) WHERE n.filePath = '${escaped}'
AND n.name CONTAINS '${pattern.replace(/'/g, "''").split(/\s+/)[0]}'
AND n.name CONTAINS '${patternFirstWord}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 3
`,
@ -143,6 +144,29 @@ export async function augment(pattern: string, cwd?: string): Promise<string> {
}
}
// When FTS indexes are unavailable (read-only DB, first run before indexes are built),
// fall back to a direct name CONTAINS query so enrichment still works.
if (symbolMatches.length === 0 && !ftsAvailable) {
const fallbackRows = await executeQuery(
repoId,
`
MATCH (n)
WHERE n.name CONTAINS '${patternFirstWord}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath
LIMIT 5
`,
).catch(() => []);
for (const sym of fallbackRows) {
symbolMatches.push({
nodeId: sym.id || sym[0],
name: sym.name || sym[1],
type: sym.type || sym[2],
filePath: sym.filePath || sym[3],
score: 1.0,
});
}
}
if (symbolMatches.length === 0) return '';
// Step 3: Batch-fetch callers/callees/processes/cohesion for top matches

View file

@ -40,8 +40,11 @@ const readConfig = (): HttpConfig | null => {
const rawDims = process.env.GITNEXUS_EMBEDDING_DIMS;
let dimensions: number | undefined;
if (rawDims !== undefined) {
if (!/^\d+$/.test(rawDims)) {
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
}
const parsed = parseInt(rawDims, 10);
if (Number.isNaN(parsed) || parsed <= 0) {
if (parsed <= 0) {
throw new Error(`GITNEXUS_EMBEDDING_DIMS must be a positive integer, got "${rawDims}"`);
}
dimensions = parsed;
@ -91,7 +94,13 @@ interface EmbeddingItem {
* @param model - Model name for the request body
* @param apiKey - Bearer token (only used in Authorization header)
* @param batchIndex - Logical batch number (for error context)
* @param attempt - Current retry attempt (internal)
* @param dimensions - Optional output-vector size. When provided, sent as
* the `dimensions` field in the request body. Endpoints that implement
* Matryoshka truncation (OpenAI text-embedding-3-*, Cohere embed-v3,
* Voyage) return a truncated vector at that size; endpoints that do not
* recognise the field may ignore it or return 400. Leave
* `GITNEXUS_EMBEDDING_DIMS` unset for strict backends that reject
* unknown fields.
*/
const httpEmbedBatch = async (
url: string,
@ -99,7 +108,16 @@ const httpEmbedBatch = async (
model: string,
apiKey: string,
batchIndex = 0,
dimensions?: number,
): Promise<EmbeddingItem[]> => {
const requestBody: { input: string[]; model: string; dimensions?: number } = {
input: batch,
model,
};
if (dimensions !== undefined) {
requestBody.dimensions = dimensions;
}
let resp: Response;
try {
resp = await resilientFetch(
@ -111,7 +129,7 @@ const httpEmbedBatch = async (
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ input: batch, model }),
body: JSON.stringify(requestBody),
},
{
breakerKey: HTTP_BREAKER_KEY,
@ -169,7 +187,14 @@ export const httpEmbed = async (texts: string[]): Promise<Float32Array[]> => {
for (let i = 0; i < texts.length; i += HTTP_BATCH_SIZE) {
const batch = texts.slice(i, i + HTTP_BATCH_SIZE);
const batchIndex = Math.floor(i / HTTP_BATCH_SIZE);
const items = await httpEmbedBatch(url, batch, config.model, config.apiKey, batchIndex);
const items = await httpEmbedBatch(
url,
batch,
config.model,
config.apiKey,
batchIndex,
config.dimensions,
);
if (items.length !== batch.length) {
throw new Error(
@ -212,7 +237,14 @@ export const httpEmbedQuery = async (text: string): Promise<number[]> => {
if (!config) throw new Error('HTTP embedding not configured');
const url = `${config.baseUrl}/embeddings`;
const items = await httpEmbedBatch(url, [text], config.model, config.apiKey);
const items = await httpEmbedBatch(
url,
[text],
config.model,
config.apiKey,
0,
config.dimensions,
);
if (!items.length) {
throw new Error(`Embedding endpoint returned empty response (${safeUrl(url)})`);
}

View file

@ -81,6 +81,8 @@ export interface AnalyzeOptions {
skipAgentsMd?: boolean;
/** Omit volatile symbol/relationship counts from AGENTS.md and CLAUDE.md. */
noStats?: boolean;
/** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */
skipSkills?: boolean;
/**
* User-provided alias for the registry `name` (#829). When set,
* forwarded to `registerRepo` so the indexed repo is stored under
@ -527,7 +529,11 @@ export async function runFullAnalysis(
processes: pipelineResult.processResult?.stats.totalProcesses,
},
undefined,
{ skipAgentsMd: options.skipAgentsMd, noStats: options.noStats },
{
skipAgentsMd: options.skipAgentsMd,
skipSkills: options.skipSkills,
noStats: options.noStats,
},
);
} catch {
// Best-effort — don't fail the entire analysis for context file issues

View file

@ -57,6 +57,7 @@ vi.mock('../../src/storage/repo-manager.js', () => ({
}));
let augment: (pattern: string, cwd?: string) => Promise<string>;
let augmentNoFts: (pattern: string, cwd?: string) => Promise<string>;
withTestLbugDB(
'augment',
@ -106,6 +107,28 @@ withTestLbugDB(
const result = await augment('日本語テスト', handle.dbPath);
expect(typeof result).toBe('string');
});
// ─── Negative-safety: fallback must stay gated on !ftsAvailable ───
//
// When FTS is available but happens to return zero BM25 hits, the
// CONTAINS fallback must NOT fire — preserving the original early-return
// semantics. If anyone later loosens the gate to `symbolMatches.length
// === 0` alone, this test fails.
it('does NOT fire CONTAINS fallback when FTS is available but BM25 returns empty', async () => {
const bm25 = await import('../../src/core/search/bm25-index.js');
const spy = vi
.spyOn(bm25, 'searchFTSFromLbug')
.mockResolvedValue({ results: [], ftsAvailable: true });
try {
// 'login' WOULD match a graph node via CONTAINS, but FTS is available
// and empty → fallback gate must hold → result must be ''.
const result = await augment('login', handle.dbPath);
expect(result).toBe('');
} finally {
spy.mockRestore();
}
});
});
},
{
@ -131,3 +154,65 @@ withTestLbugDB(
},
},
);
// ─── FTS-unavailable suite: exercises the CONTAINS fallback branch ────────────
//
// No ftsIndexes → searchFTSFromLbug returns ftsAvailable: false → fallback fires.
// Same seed data so 'login' still exists as a graph node.
withTestLbugDB(
'augment-no-fts',
(handle) => {
describe('augment() — FTS indexes unavailable (CONTAINS fallback)', () => {
it('falls back to CONTAINS query and returns enrichment when FTS is unavailable', async () => {
const result = await augmentNoFts('login', handle.dbPath);
expect(result.length).toBeGreaterThan(0);
expect(result).toContain('[GitNexus]');
});
it("returns empty string for whitespace-only pattern (CONTAINS '' guard)", async () => {
const result = await augmentNoFts(' ', handle.dbPath);
expect(result).toBe('');
});
it('returns empty string when no nodes match the CONTAINS query', async () => {
const result = await augmentNoFts('nxyz_notfound', handle.dbPath);
expect(result).toBe('');
});
it('returns empty string when fallback CONTAINS query throws', async () => {
const poolAdapter = await import('../../src/core/lbug/pool-adapter.js');
const spy = vi
.spyOn(poolAdapter, 'executeQuery')
.mockRejectedValue(new Error('simulated DB error'));
try {
const result = await augmentNoFts('login', handle.dbPath);
expect(result).toBe('');
} finally {
spy.mockRestore();
}
});
});
},
{
seed: AUGMENT_SEED_DATA,
// Intentionally no ftsIndexes — forces searchFTSFromLbug to return ftsAvailable: false
poolAdapter: true,
afterSetup: async (handle) => {
const { listRegisteredRepos } = await import('../../src/storage/repo-manager.js');
(listRegisteredRepos as ReturnType<typeof vi.fn>).mockResolvedValue([
{
name: handle.repoId,
path: handle.dbPath,
storagePath: handle.tmpHandle.dbPath,
indexedAt: new Date().toISOString(),
lastCommit: 'abc123',
},
]);
const engine = await import('../../src/core/augmentation/engine.js');
augmentNoFts = engine.augment;
},
},
);

View file

@ -137,6 +137,101 @@ describe('generateAIContextFiles', () => {
}
});
it('does not create .claude/skills/gitnexus/ when skipSkills is true (#742)', async () => {
// Regression guard for #742. The --skip-skills flag must prevent
// installSkills() from writing the 6 standard skill dirs into the
// analyzed repo. Per-test tmpdir so we start from a known-clean
// slate — the shared tmpDir from beforeAll may already contain
// .claude/skills/gitnexus/ from an earlier test.
const skipDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-skip-skills-'));
const skipStorage = path.join(skipDir, '.gitnexus');
await fs.mkdir(skipStorage, { recursive: true });
try {
const stats = { nodes: 50, edges: 100, processes: 5 };
const result = await generateAIContextFiles(
skipDir,
skipStorage,
'TestProject',
stats,
undefined,
{ skipSkills: true },
);
expect(result.files).toContain('.claude/skills/gitnexus/ (skipped via --skip-skills)');
await expect(
fs.access(path.join(skipDir, '.claude', 'skills', 'gitnexus')),
).rejects.toThrow();
} finally {
await fs.rm(skipDir, { recursive: true, force: true });
}
});
it('writes nothing when both skipAgentsMd and skipSkills are true (--index-only, #742)', async () => {
// Regression guard for #742. analyzeCommand() resolves --index-only
// into BOTH skipAgentsMd=true and skipSkills=true. This test pins
// the resolved-flag combination so a future regression that drops
// either guard fails here. Per-test tmpdir for the same reason as
// the skipSkills test above.
const idxDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-index-only-'));
const idxStorage = path.join(idxDir, '.gitnexus');
await fs.mkdir(idxStorage, { recursive: true });
try {
const stats = { nodes: 50, edges: 100, processes: 5 };
const result = await generateAIContextFiles(
idxDir,
idxStorage,
'TestProject',
stats,
undefined,
{ skipAgentsMd: true, skipSkills: true },
);
expect(result.files).toContain('AGENTS.md (skipped via --skip-agents-md)');
expect(result.files).toContain('CLAUDE.md (skipped via --skip-agents-md)');
expect(result.files).toContain('.claude/skills/gitnexus/ (skipped via --skip-skills)');
await expect(fs.access(path.join(idxDir, 'AGENTS.md'))).rejects.toThrow();
await expect(fs.access(path.join(idxDir, 'CLAUDE.md'))).rejects.toThrow();
await expect(fs.access(path.join(idxDir, '.claude', 'skills', 'gitnexus'))).rejects.toThrow();
} finally {
await fs.rm(idxDir, { recursive: true, force: true });
}
});
it('omits standard skill references from AGENTS.md/CLAUDE.md when skipSkills is true (#742)', async () => {
// The skills routing table in AGENTS.md/CLAUDE.md points agents at
// .claude/skills/gitnexus/*/SKILL.md files installed by installSkills().
// When --skip-skills suppresses that install but AGENTS.md/CLAUDE.md
// are still written, the routing table must NOT name files that don't
// exist — otherwise every agent load incurs 6 failed reads and the
// routing instructions are worthless. Per-test tmpdir so the assertions
// are not contaminated by a CLAUDE.md from an earlier test.
const noStdDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-ai-ctx-no-std-skills-'));
const noStdStorage = path.join(noStdDir, '.gitnexus');
await fs.mkdir(noStdStorage, { recursive: true });
try {
const stats = { nodes: 50, edges: 100, processes: 5 };
await generateAIContextFiles(noStdDir, noStdStorage, 'TestProject', stats, undefined, {
skipSkills: true,
});
const content = await fs.readFile(path.join(noStdDir, 'CLAUDE.md'), 'utf-8');
expect(content).not.toContain('gitnexus-exploring/SKILL.md');
expect(content).not.toContain('gitnexus-impact-analysis/SKILL.md');
expect(content).not.toContain('gitnexus-debugging/SKILL.md');
expect(content).not.toContain('gitnexus-refactoring/SKILL.md');
expect(content).not.toContain('gitnexus-guide/SKILL.md');
expect(content).not.toContain('gitnexus-cli/SKILL.md');
// The load-bearing imperative sections must still ship — only the
// routing rows are conditional.
expect(content).toContain('## Always Do');
expect(content).toContain('## Never Do');
expect(content).toContain('gitnexus://repo/TestProject/context');
} finally {
await fs.rm(noStdDir, { recursive: true, force: true });
}
});
it('preserves manual AGENTS.md and CLAUDE.md edits when skipAgentsMd is enabled', async () => {
const stats = { nodes: 42, edges: 84, processes: 3 };
const agentsPath = path.join(tmpDir, 'AGENTS.md');

View file

@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { shouldGenerateCommunitySkillFiles } from '../../src/cli/analyze.js';
describe('shouldGenerateCommunitySkillFiles (#742 / PR 1485)', () => {
it('is false when --index-only is set even if --skills and pipelineResult are present', () => {
expect(shouldGenerateCommunitySkillFiles({ skills: true, indexOnly: true }, { ok: true })).toBe(
false,
);
});
it('is false when pipelineResult is missing', () => {
expect(shouldGenerateCommunitySkillFiles({ skills: true, indexOnly: false }, null)).toBe(false);
expect(shouldGenerateCommunitySkillFiles({ skills: true }, undefined)).toBe(false);
});
it('is true when --skills is set, pipeline exists, and not index-only', () => {
expect(
shouldGenerateCommunitySkillFiles({ skills: true, indexOnly: false }, { communities: [] }),
).toBe(true);
expect(shouldGenerateCommunitySkillFiles({ skills: true }, { x: 1 })).toBe(true);
});
it('is false when --skills is omitted', () => {
expect(shouldGenerateCommunitySkillFiles({ indexOnly: false }, { x: 1 })).toBe(false);
expect(shouldGenerateCommunitySkillFiles(undefined, { x: 1 })).toBe(false);
});
});

View file

@ -96,6 +96,73 @@ describe('HTTP embedding backend', () => {
expect(result.length).toBe(384);
});
it('omits dimensions from request body when GITNEXUS_EMBEDDING_DIMS is unset', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
// GITNEXUS_EMBEDDING_DIMS intentionally unset
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: mockVec }] }),
}),
);
const { embedText } = await import('../../src/core/embeddings/embedder.js');
await embedText('test text');
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
// Backends that reject unknown fields must see the pre-existing
// request shape. The field must be absent, not `undefined`.
expect('dimensions' in body).toBe(false);
});
it('forwards GITNEXUS_EMBEDDING_DIMS as dimensions in request body', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: vec1024 }] }),
}),
);
const { embedText } = await import('../../src/core/embeddings/embedder.js');
const result = await embedText('test text');
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(body.dimensions).toBe(1024);
expect(body.model).toBe('text-embedding-3-large');
expect(result.length).toBe(1024);
});
it('forwards dimensions on the single-query path', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
const vec512 = Array.from({ length: 512 }, (_, i) => i / 512);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: vec512 }] }),
}),
);
const mod = await import('../../src/mcp/core/embedder.js');
const result = await mod.embedQuery('query text');
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
expect(body.dimensions).toBe(512);
expect(result.length).toBe(512);
});
it('retries on server error', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
@ -191,6 +258,51 @@ describe('HTTP embedding backend', () => {
expect(results).toHaveLength(70);
});
it('forwards dimensions in every batch when splitting large inputs', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
const vec512 = Array.from({ length: 512 }, (_, i) => i / 512);
const makeResp = (n: number) => ({
ok: true,
json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: vec512 })) }),
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValueOnce(makeResp(64)).mockResolvedValueOnce(makeResp(6)),
);
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
const results = await embedBatch(Array.from({ length: 70 }, (_, i) => `text ${i}`));
expect(fetch).toHaveBeenCalledTimes(2);
expect(results).toHaveLength(70);
// Verify dimensions is sent in BOTH batch requests
const body0 = JSON.parse((fetch as any).mock.calls[0][1].body);
const body1 = JSON.parse((fetch as any).mock.calls[1][1].body);
expect(body0.dimensions).toBe(512);
expect(body1.dimensions).toBe(512);
});
it('rejects non-numeric GITNEXUS_EMBEDDING_DIMS values', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc';
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ embedding: mockVec }] }),
}),
);
const { embedText } = await import('../../src/core/embeddings/embedder.js');
await expect(embedText('test')).rejects.toThrow('must be a positive integer');
});
it('rejects initEmbedder when using HTTP backend', async () => {
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';

View file

@ -17,9 +17,41 @@ describe('--skip-git CLI flag', () => {
expect(helpOutput).toContain('--skip-git');
expect(helpOutput).toContain('--skip-agents-md');
expect(helpOutput).toContain('--skip-skills');
expect(helpOutput).toContain('--index-only');
expect(helpOutput).not.toContain('--no-git');
});
it('warns when --index-only overrides --skills (PR 1485)', () => {
// `--index-only` suppresses the post-index skill step that `--skills`
// would otherwise trigger. Without an explicit warning, the user sees a
// pipeline re-index complete and silently no skill files written — the
// silent-contradiction case flagged in PR 1485 review.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-index-only-skills-'));
const gitnexusHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-index-only-skills-home-'));
// Make tmpDir a git repo so analyze accepts it without --skip-git.
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'export const a = 1;\n');
const env = {
...process.env,
HOME: gitnexusHome,
GITNEXUS_HOME: gitnexusHome,
GITNEXUS_LBUG_EXTENSION_INSTALL: 'never',
};
try {
const output = execSync(
`node "${cliPath}" analyze "${tmpDir}" --index-only --skills --skip-agents-md`,
{ encoding: 'utf8', timeout: 60000, env },
);
expect(output).toContain('--index-only overrides --skills');
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
fs.rmSync(gitnexusHome, { recursive: true, force: true });
}
});
it('rejects non-git folder without --skip-git', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-no-git-'));
fs.writeFileSync(path.join(tmpDir, 'test.ts'), 'export const x = 1;');