diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 41ba6c73e..8e18fed7d 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -28,6 +28,7 @@ interface RepoStats { export interface AIContextOptions { skipAgentsMd?: boolean; noStats?: boolean; + skipSkills?: boolean; } const GITNEXUS_START_MARKER = ''; @@ -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 }; diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 47745c575..d5a7638f9 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -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 | 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 { diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index e4a455d40..d38675f03 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -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', diff --git a/gitnexus/src/core/augmentation/engine.ts b/gitnexus/src/core/augmentation/engine.ts index 42087e4b0..81e41077e 100644 --- a/gitnexus/src/core/augmentation/engine.ts +++ b/gitnexus/src/core/augmentation/engine.ts @@ -86,6 +86,9 @@ async function findRepoForCwd(cwd: string): Promise<{ export async function augment(pattern: string, cwd?: string): Promise { 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 { } // 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 { 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 { } } + // 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 diff --git a/gitnexus/src/core/embeddings/http-client.ts b/gitnexus/src/core/embeddings/http-client.ts index e3fb06045..e8e9073ff 100644 --- a/gitnexus/src/core/embeddings/http-client.ts +++ b/gitnexus/src/core/embeddings/http-client.ts @@ -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 => { + 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 => { 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 => { 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)})`); } diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 7227fd945..fa2757f45 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -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 diff --git a/gitnexus/test/integration/augmentation.test.ts b/gitnexus/test/integration/augmentation.test.ts index aed09d61f..60c4170fd 100644 --- a/gitnexus/test/integration/augmentation.test.ts +++ b/gitnexus/test/integration/augmentation.test.ts @@ -57,6 +57,7 @@ vi.mock('../../src/storage/repo-manager.js', () => ({ })); let augment: (pattern: string, cwd?: string) => Promise; +let augmentNoFts: (pattern: string, cwd?: string) => Promise; 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).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; + }, + }, +); diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index bbaecb932..71d7ddbdc 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -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'); diff --git a/gitnexus/test/unit/analyze-community-skills-gate.test.ts b/gitnexus/test/unit/analyze-community-skills-gate.test.ts new file mode 100644 index 000000000..35eaa5c77 --- /dev/null +++ b/gitnexus/test/unit/analyze-community-skills-gate.test.ts @@ -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); + }); +}); diff --git a/gitnexus/test/unit/http-embedder.test.ts b/gitnexus/test/unit/http-embedder.test.ts index 0dea77758..4ac683095 100644 --- a/gitnexus/test/unit/http-embedder.test.ts +++ b/gitnexus/test/unit/http-embedder.test.ts @@ -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'; diff --git a/gitnexus/test/unit/skip-git-cli.test.ts b/gitnexus/test/unit/skip-git-cli.test.ts index 069a9686f..80c07ed17 100644 --- a/gitnexus/test/unit/skip-git-cli.test.ts +++ b/gitnexus/test/unit/skip-git-cli.test.ts @@ -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;');