import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; import fsSync from 'node:fs'; import fs from 'fs/promises'; import path from 'path'; import os from 'os'; import { loadAnalyzeConfig, loadAnalyzeConfigStrict, mergeAnalyzeOptions, resolveDefaultBranch, validateBranchName, sanitizeDetectedBranch, GitNexusRcError, GITNEXUS_RC_FILENAME, DEFAULT_BRANCH_FALLBACK, } from '../../src/cli/analyze-config.js'; import type { AnalyzeOptions } from '../../src/cli/analyze.js'; import { MAX_REPO_CONTROL_FILE_BYTES, readRepoControlFile, } from '../../src/config/repo-control-file.js'; describe('analyze-config (.gitnexusrc support, #243)', () => { let dir: string; beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-')); }); afterEach(async () => { await fs.rm(dir, { recursive: true, force: true }); }); const writeRc = (contents: string) => fs.writeFile(path.join(dir, GITNEXUS_RC_FILENAME), contents); // ── loadAnalyzeConfig ────────────────────────────────────────────── it('returns undefined when no .gitnexusrc exists (the normal case)', () => { expect(loadAnalyzeConfig(dir)).toBeUndefined(); }); it('rejects an oversized repository config before parsing', async () => { await writeRc(' '.repeat(MAX_REPO_CONTROL_FILE_BYTES + 1)); await expect(loadAnalyzeConfigStrict(dir)).rejects.toThrow(/exceeds/); }); it('keeps the read bounded if a control file grows after its size check', async () => { await writeRc('{}'); const fstatSync = fsSync.fstatSync; const stat = vi.spyOn(fsSync, 'fstatSync').mockImplementation((fd) => { const opened = fstatSync(fd); Object.defineProperty(opened, 'size', { value: MAX_REPO_CONTROL_FILE_BYTES + 1 }); return opened; }); try { await expect(readRepoControlFile(dir, GITNEXUS_RC_FILENAME)).rejects.toThrow(/exceeds/); expect(stat).toHaveBeenCalledOnce(); } finally { stat.mockRestore(); } }); it('rejects a hardlinked repository config', async () => { const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-hardlink-')); try { const target = path.join(outside, 'config.json'); await fs.writeFile(target, JSON.stringify({ workers: '8' })); await fs.link(target, path.join(dir, GITNEXUS_RC_FILENAME)); await expect(loadAnalyzeConfigStrict(dir)).rejects.toThrow(/hard link/); } finally { await fs.rm(outside, { recursive: true, force: true }); } }); it.skipIf(process.platform === 'win32')( 'rejects a FIFO before opening it for reading', async () => { const fifo = path.join(dir, GITNEXUS_RC_FILENAME); execFileSync('mkfifo', [fifo]); let timeout: ReturnType | undefined; try { await expect( Promise.race([ readRepoControlFile(dir, GITNEXUS_RC_FILENAME), new Promise((_resolve, reject) => { timeout = setTimeout(() => reject(new Error('FIFO read did not fail promptly')), 500); }), ]), ).rejects.toThrow(/regular file/); } finally { if (timeout !== undefined) clearTimeout(timeout); } }, ); it.skipIf(process.platform === 'win32')( 'rejects a final-file symlink for repository config', async () => { const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-outside-')); try { const target = path.join(outside, 'config.json'); await fs.writeFile(target, JSON.stringify({ workers: '8' })); await fs.symlink(target, path.join(dir, GITNEXUS_RC_FILENAME), 'file'); await expect(loadAnalyzeConfigStrict(dir)).rejects.toThrow(/symbolic link/); } finally { await fs.rm(outside, { recursive: true, force: true }); } }, ); it('throws an actionable error on invalid JSON, naming the file', async () => { await writeRc('{ not valid json '); expect(() => loadAnalyzeConfig(dir)).toThrow(GitNexusRcError); try { loadAnalyzeConfig(dir); } catch (err) { expect((err as Error).message).toContain(GITNEXUS_RC_FILENAME); expect((err as Error).message).toMatch(/not valid JSON/i); } }); it('rejects a non-object top-level value', async () => { await writeRc('["develop"]'); expect(() => loadAnalyzeConfig(dir)).toThrow(/must contain a JSON object/); }); it('fails closed on an unknown key (typo protection)', async () => { await writeRc(JSON.stringify({ defalutBranch: 'develop' })); expect(() => loadAnalyzeConfig(dir)).toThrow(/Unknown key "defalutBranch"/); }); it('parses process-detection budget keys as numeric strings (#3313)', async () => { await writeRc( JSON.stringify({ maxProcesses: 40, maxProcessBranching: '2', maxProcessTraceDepth: 8, maxEntryPointCandidates: 400, }), ); expect(loadAnalyzeConfig(dir)).toEqual({ maxProcesses: '40', maxProcessBranching: '2', maxProcessTraceDepth: '8', maxEntryPointCandidates: '400', }); }); it('lets a nested analyze block override flat process-detection keys (#3313)', async () => { await writeRc( JSON.stringify({ maxProcesses: 80, analyze: { maxProcesses: 25 }, }), ); expect(loadAnalyzeConfig(dir)).toEqual({ maxProcesses: '25' }); }); it('accepts embeddingBaseUrl / embeddingModel but rejects embeddingDims (CLI/env-only)', async () => { // URL + MODEL are read lazily at runtime, so they are valid config keys. await writeRc(JSON.stringify({ embeddingBaseUrl: 'http://h/v1', embeddingModel: 'm' })); expect(loadAnalyzeConfig(dir)).toEqual({ embeddingBaseUrl: 'http://h/v1', embeddingModel: 'm', }); // DIMS is read at module-load (before .gitnexusrc), so it is intentionally // not a config key — a typo'd/intended value fails closed rather than // silently sizing nothing. await writeRc(JSON.stringify({ embeddingDims: 4096 })); expect(() => loadAnalyzeConfig(dir)).toThrow(/Unknown key "embeddingDims"/); }); it('parses the flat form and maps aliases onto AnalyzeOptions', async () => { await writeRc( JSON.stringify({ defaultBranch: 'develop', skipContextFiles: true, skipSkills: true, embeddings: true, workerTimeout: 60, }), ); const cfg = loadAnalyzeConfig(dir); expect(cfg).toEqual({ defaultBranch: 'develop', skipAgentsMd: true, // skipContextFiles → skipAgentsMd skipSkills: true, embeddings: true, workerTimeout: '60', // numeric → string (Commander contract) }); }); it('normalizes the pdg opt-in (#2081) and rejects a non-boolean value', async () => { await writeRc(JSON.stringify({ pdg: true })); expect(loadAnalyzeConfig(dir)).toEqual({ pdg: true }); await writeRc(JSON.stringify({ pdg: false })); expect(loadAnalyzeConfig(dir)).toEqual({ pdg: false }); await writeRc(JSON.stringify({ pdg: 'yes' })); expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a boolean/); }); it('parses the nested analyze form', async () => { await writeRc(JSON.stringify({ analyze: { defaultBranch: 'master', skipSkills: true } })); expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'master', skipSkills: true }); }); it('lets the nested analyze block override flat keys for the same option', async () => { await writeRc( JSON.stringify({ defaultBranch: 'flat-branch', skipSkills: false, analyze: { defaultBranch: 'nested-branch', skipSkills: true }, }), ); expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'nested-branch', skipSkills: true, }); }); it('supports the legacy "branch" alias and the issue-comment shape', async () => { await writeRc(JSON.stringify({ branch: 'develop', skipAiContext: true, embeddings: false })); expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'develop', skipAgentsMd: true, // skipAiContext → skipAgentsMd embeddings: false, }); }); it('maps noStats onto stats (negated)', async () => { await writeRc(JSON.stringify({ noStats: true })); expect(loadAnalyzeConfig(dir)).toEqual({ stats: false }); }); it('rejects two aliases that configure the same option at the same level', async () => { await writeRc(JSON.stringify({ skipContextFiles: true, skipAgentsMd: false })); expect(() => loadAnalyzeConfig(dir)).toThrow(/both configure the same option/); }); it('requires booleans to be booleans', async () => { await writeRc(JSON.stringify({ skipSkills: 'yes' })); expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a boolean/); }); it('requires the nested analyze value to be an object', async () => { await writeRc(JSON.stringify({ analyze: 'develop' })); expect(() => loadAnalyzeConfig(dir)).toThrow(/"analyze" must be a JSON object/); }); it('normalizes numeric embeddings cap to a string and validates it', async () => { await writeRc(JSON.stringify({ embeddings: 1000 })); expect(loadAnalyzeConfig(dir)).toEqual({ embeddings: '1000' }); await writeRc(JSON.stringify({ embeddings: -1 })); expect(() => loadAnalyzeConfig(dir)).toThrow(/non-negative integer/); }); it('rejects an invalid branch string in config (control characters)', async () => { // Build the JSON manually so the control char survives. await writeRc('{"defaultBranch": "de\\u0007velop"}'); expect(() => loadAnalyzeConfig(dir)).toThrow(/control or hidden/); }); // ── fetchWrappers (#1589/#1852 residual) ─────────────────────────── it('normalizes a fetchWrappers string array (de-duped)', async () => { await writeRc(JSON.stringify({ fetchWrappers: ['doRequest', 'apiClient.get', 'doRequest'] })); expect(loadAnalyzeConfig(dir)).toEqual({ fetchWrappers: ['doRequest', 'apiClient.get'] }); }); it('rejects a non-array fetchWrappers value', async () => { await writeRc(JSON.stringify({ fetchWrappers: 'doRequest' })); expect(() => loadAnalyzeConfig(dir)).toThrow(/must be an array of strings/); }); it('rejects a fetchWrappers entry with regex / non-identifier characters', async () => { await writeRc(JSON.stringify({ fetchWrappers: ['do(.*)Request'] })); expect(() => loadAnalyzeConfig(dir)).toThrow(/must be an identifier or member name/); }); it('rejects an empty fetchWrappers array', async () => { await writeRc(JSON.stringify({ fetchWrappers: [] })); expect(() => loadAnalyzeConfig(dir)).toThrow(/at least one string/); }); // ── Spring Actuator runtime enrichment (#2418) ──────────────────── it('normalizes a Spring Actuator snapshot path', async () => { await writeRc(JSON.stringify({ springActuator: ' fixtures/actuator snapshots ' })); expect(loadAnalyzeConfig(dir)).toEqual({ springActuator: 'fixtures/actuator snapshots', }); }); it('rejects empty, non-string, and hidden-character Actuator paths', async () => { await writeRc(JSON.stringify({ springActuator: ' ' })); expect(() => loadAnalyzeConfig(dir)).toThrow(/must not be empty/); await writeRc(JSON.stringify({ springActuator: 42 })); expect(() => loadAnalyzeConfig(dir)).toThrow(/file or directory path/); await writeRc('{"springActuator":"actuator\\u0007"}'); expect(() => loadAnalyzeConfig(dir)).toThrow(/control or hidden/); }); // ── validateBranchName ───────────────────────────────────────────── it('validateBranchName trims and accepts normal branch names', () => { expect(validateBranchName(' develop ', 'src')).toBe('develop'); expect(validateBranchName('feature/foo-bar', 'src')).toBe('feature/foo-bar'); expect(validateBranchName('release/1.2', 'src')).toBe('release/1.2'); }); it('validateBranchName rejects empty, whitespace, ref-special, leading dash, and "."', () => { expect(() => validateBranchName(' ', 'src')).toThrow(/must not be empty/); expect(() => validateBranchName('foo bar', 'src')).toThrow(/whitespace/); expect(() => validateBranchName('foo~1', 'src')).toThrow(/not allowed in a git ref/); expect(() => validateBranchName('foo:bar', 'src')).toThrow(/not allowed in a git ref/); expect(() => validateBranchName('-foo', 'src')).toThrow(/must not start with "-"/); expect(() => validateBranchName('foo..bar', 'src')).toThrow(/must not contain ".."/); }); it('validateBranchName rejects the ref shapes git check-ref-format rejects', () => { // These previously passed validation and failed later in the git subprocess, // which over HTTP meant a 202 and a background failure instead of a 400. expect(() => validateBranchName('feature.lock', 'src')).toThrow(/must not end with "\.lock"/); expect(() => validateBranchName('refs/heads.lock/x', 'src')).toThrow( /must not end with "\.lock"/, ); expect(() => validateBranchName('/feature', 'src')).toThrow(/must not start or end with "\/"/); expect(() => validateBranchName('feature/', 'src')).toThrow(/must not start or end with "\/"/); expect(() => validateBranchName('feature//next', 'src')).toThrow(/consecutive slashes/); expect(() => validateBranchName('@', 'src')).toThrow(/single character "@"/); expect(() => validateBranchName('feature@{1}', 'src')).toThrow(/must not contain "@\{"/); expect(() => validateBranchName('.hidden', 'src')).toThrow(/starting with "\."/); expect(() => validateBranchName('feature/.hidden', 'src')).toThrow(/starting with "\."/); expect(() => validateBranchName('feature.', 'src')).toThrow(/end with "\."/); }); it('validateBranchName still accepts the real branch shapes those rules must not catch', () => { // A dot, a slash and an @ are all legal in the middle of a ref — the new // rules must reject only what git itself would. expect(validateBranchName('release/1.2.3', 'src')).toBe('release/1.2.3'); expect(validateBranchName('feature/lockfile-bump', 'src')).toBe('feature/lockfile-bump'); expect(validateBranchName('user@host', 'src')).toBe('user@host'); expect(validateBranchName('v1.0', 'src')).toBe('v1.0'); expect(validateBranchName('a/b/c', 'src')).toBe('a/b/c'); }); it('validateBranchName rejects a newline / control character', () => { expect(() => validateBranchName('main\nrm -rf', 'src')).toThrow(/control or hidden|whitespace/); }); it('sanitizeDetectedBranch returns undefined for junk, the name otherwise', () => { expect(sanitizeDetectedBranch('develop')).toBe('develop'); expect(sanitizeDetectedBranch('with space')).toBeUndefined(); expect(sanitizeDetectedBranch(null)).toBeUndefined(); expect(sanitizeDetectedBranch('')).toBeUndefined(); }); // ── resolveDefaultBranch ─────────────────────────────────────────── it('resolveDefaultBranch: CLI wins over config and detection', () => { expect( resolveDefaultBranch({ cliBranch: 'cli', configBranch: 'cfg', detectedBranch: 'det' }), ).toBe('cli'); }); it('resolveDefaultBranch: config wins over detection', () => { expect(resolveDefaultBranch({ configBranch: 'develop', detectedBranch: 'main' })).toBe( 'develop', ); }); it('resolveDefaultBranch: auto-detected branch used when no CLI/config', () => { expect(resolveDefaultBranch({ detectedBranch: 'trunk' })).toBe('trunk'); }); it('resolveDefaultBranch: falls back to "main" with nothing available', () => { expect(resolveDefaultBranch({})).toBe(DEFAULT_BRANCH_FALLBACK); expect(resolveDefaultBranch({ detectedBranch: null })).toBe('main'); // An unusable detected branch is ignored, not surfaced as an error. expect(resolveDefaultBranch({ detectedBranch: 'bad branch' })).toBe('main'); }); it('resolveDefaultBranch: invalid CLI branch throws (user error)', () => { expect(() => resolveDefaultBranch({ cliBranch: 'bad branch' })).toThrow(GitNexusRcError); expect(() => resolveDefaultBranch({ cliBranch: 'bad branch' })).toThrow(/--default-branch/); }); // ── mergeAnalyzeOptions ──────────────────────────────────────────── it('mergeAnalyzeOptions: returns CLI unchanged when there is no config', () => { const cli: AnalyzeOptions = { force: true }; expect(mergeAnalyzeOptions(cli, undefined)).toBe(cli); }); it('mergeAnalyzeOptions: config fills options the CLI left unset', () => { const merged = mergeAnalyzeOptions({}, { skipAgentsMd: true, workerTimeout: '60' }); expect(merged.skipAgentsMd).toBe(true); expect(merged.workerTimeout).toBe('60'); }); it('mergeAnalyzeOptions: CLI value wins over config', () => { const merged = mergeAnalyzeOptions({ workerTimeout: '5' }, { workerTimeout: '60' }); expect(merged.workerTimeout).toBe('5'); }); it('mergeAnalyzeOptions: an explicit CLI false overrides a config true', () => { const merged = mergeAnalyzeOptions({ skipSkills: false }, { skipSkills: true }); expect(merged.skipSkills).toBe(false); }); it('mergeAnalyzeOptions: config stats applies unless --no-stats was passed', () => { // Commander default (stats:true) with config stats:false → config wins (off). expect(mergeAnalyzeOptions({ stats: true }, { stats: false }).stats).toBe(false); // Explicit --no-stats (stats:false) is never overridden back on by config. expect(mergeAnalyzeOptions({ stats: false }, { stats: true }).stats).toBe(false); // No config stats → CLI value preserved. expect(mergeAnalyzeOptions({ stats: true }, { skipSkills: true }).stats).toBe(true); }); it('mergeAnalyzeOptions: does NOT forward defaultBranch (resolver owns it) (#1996)', () => { // Pins the deliberate exclusion: defaultBranch is resolved via // resolveDefaultBranch (CLI > config > detect > main), not the generic merge. const merged = mergeAnalyzeOptions({}, { defaultBranch: 'develop', skipSkills: true }); expect(merged.skipSkills).toBe(true); expect(merged.defaultBranch).toBeUndefined(); }); // ── #1996 tri-review hardening ───────────────────────────────────── it('validateBranchName rejects a backtick (breaks generated Markdown) (#1996)', () => { expect(() => validateBranchName('main`evil', 'src')).toThrow(/backtick/); expect(() => validateBranchName('a`b', 'src')).toThrow(GitNexusRcError); // sanitizeDetectedBranch swallows it → falls back via the resolver chain. expect(sanitizeDetectedBranch('main`evil')).toBeUndefined(); }); it('validateBranchName enforces the 255-char max (#1996)', () => { expect(validateBranchName('a'.repeat(255), 'src')).toBe('a'.repeat(255)); expect(() => validateBranchName('a'.repeat(256), 'src')).toThrow(/too long/); }); it('validateBranchName rejects HEAD (case-sensitive) and accepts head (#3199)', () => { expect(() => validateBranchName('HEAD', 'src')).toThrow(GitNexusRcError); expect(() => validateBranchName('HEAD', 'src')).toThrow(/must not be "HEAD"/); expect(() => validateBranchName(' HEAD ', 'src')).toThrow(GitNexusRcError); expect(validateBranchName('head', 'src')).toBe('head'); }); it('validateBranchName rejects a force-refspec "+" prefix (#3199)', () => { expect(() => validateBranchName('+main', 'src')).toThrow(GitNexusRcError); expect(() => validateBranchName('+main', 'src')).toThrow(/must not start with "\+"/); expect(() => validateBranchName('+develop', 'src')).toThrow(GitNexusRcError); expect(() => validateBranchName('+develop', 'src')).toThrow(/must not start with "\+"/); }); it('rejects Markdown-significant characters in a config name, allows real names (#1996)', async () => { await writeRc(JSON.stringify({ name: '**evil**' })); expect(() => loadAnalyzeConfig(dir)).toThrow(/Markdown-significant/); await writeRc(JSON.stringify({ name: 'repo`x' })); expect(() => loadAnalyzeConfig(dir)).toThrow(/Markdown-significant/); // Underscores, dots, dashes, slashes are legitimate in repo names. await writeRc(JSON.stringify({ name: 'my_org/my-repo.v2' })); expect(loadAnalyzeConfig(dir)).toEqual({ name: 'my_org/my-repo.v2' }); }); it('reports inherited keys (__proto__, constructor) as Unknown key, not a kind error (#1996)', async () => { for (const key of ['__proto__', 'constructor', 'toString']) { await writeRc(`{"${key}": true}`); expect(() => loadAnalyzeConfig(dir), key).toThrow(/Unknown key/); } // And no prototype pollution leaked from the attempt. expect(({} as Record).polluted).toBeUndefined(); }); it('strips a leading UTF-8 BOM before parsing (#1996)', async () => { const BOM = String.fromCharCode(0xfeff); await writeRc(BOM + JSON.stringify({ defaultBranch: 'develop' })); expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'develop' }); }); });