mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
* feat(analyze): expose process-detection budget overrides (#3313) Operators can raise or lower process count, branching, trace depth, and the entry-point candidate pool via CLI, .gitnexusrc, or GITNEXUS_* without changing shipped defaults. A budget-only change re-detects flows on the next analyze without --force. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): say invalid budget flags still honor env A rejected --max-processes value was described as falling back to the built-in default even when GITNEXUS_MAX_* still won the next precedence tier. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(analyze): share process-detection defaults and skip unused walks Keep DEFAULT_CONFIG aligned with the budget resolver and count symbols only when maxProcesses is still dynamic. Co-authored-by: Cursor <cursoragent@cursor.com> * style(analyze): wrap process-detection budget files for prettier Co-authored-by: Cursor <cursoragent@cursor.com> * docs(analyze): name the real process-detection default formula Co-authored-by: Cursor <cursoragent@cursor.com> * docs(analyze): stop calling maxProcesses*2 a hard trace quota Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): say invalid env budget tokens fall back to defaults Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): recertify process-detection after in-place FTS abort (#3324) Persist processDetection.uncertified on the in-place FTS dirty stamp when the budget mismatched so a flagless retry cannot keep rewritten flows. Qualify .gitnexusrc fail-fast copy and tighten related tests. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(analyze): skip live dirty stamp on atomic incremental (#3324) POSIX atomic incremental mutates a staging copy, so stamping live incrementalInProgress before swap made a crash force-rebuild a healthy index. Align analyze --help with CLI > .gitnexusrc > env > default. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(changelog): drop the atomic-incremental dirty-stamp note The code fix stays; Unreleased no longer lists that recovery change. Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): survive FTS SIGSEGV in --limit e2e CREATE_FTS_INDEX can kill the setup analyze on some WSL hosts (status null). Rebuild with --skip-fts and skip BM25-only query --limit cases unless GITNEXUS_REQUIRE_FTS=1. Refs #3324 Co-authored-by: Cursor <cursoragent@cursor.com> * test(cli): mark update-check child at import Writing refresh-started from fetch() raced a 30s poll against cold tsx boot on a loaded default-project worker. Refs #3324 Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3324) Isolate default-budget FTS crash-marker tests from GITNEXUS_MAX_* env, assert uncertify-before-FTS order and deferred flow detection on park recovery, drop the dangling "then" from entry-point help, and correct stale streamGraphEmit docs without skipping the process-detection stamp. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(changelog): drop Unreleased process-detection notes Keep the #3313 / #3322 code; Unreleased changelog matches main until release. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
254 lines
9.5 KiB
TypeScript
254 lines
9.5 KiB
TypeScript
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
|
|
/**
|
|
* End-to-end wiring tests for project-local `.gitnexusrc` (#243).
|
|
*
|
|
* Unlike analyze-config.test.ts (which unit-tests the pure config module), these
|
|
* drive the REAL `analyzeCommand` with a REAL `.gitnexusrc` on disk and a real
|
|
* `analyze-config` module — only the heavy pipeline (`runFullAnalysis`,
|
|
* `generateAIContextFiles`, skill-gen, LadybugDB) and git are mocked. They fail
|
|
* if config is parsed but not threaded into the analyze/context path.
|
|
*/
|
|
|
|
const {
|
|
runFullAnalysisMock,
|
|
generateAIContextFilesMock,
|
|
refreshBaseRefLineMock,
|
|
generateSkillFilesMock,
|
|
cliErrorMock,
|
|
getDefaultBranchMock,
|
|
} = vi.hoisted(() => ({
|
|
runFullAnalysisMock: vi.fn(),
|
|
generateAIContextFilesMock: vi.fn(async () => ({ files: [] as string[] })),
|
|
refreshBaseRefLineMock: vi.fn(async () => ({ files: [] as string[] })),
|
|
generateSkillFilesMock: vi.fn(async () => ({
|
|
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
|
|
outputPath: '/repo/.claude/skills',
|
|
})),
|
|
cliErrorMock: vi.fn(),
|
|
getDefaultBranchMock: vi.fn<(p: string) => string | null>(() => null),
|
|
}));
|
|
|
|
vi.mock('../../src/core/run-analyze.js', () => ({ runFullAnalysis: runFullAnalysisMock }));
|
|
vi.mock('../../src/cli/ai-context.js', () => ({
|
|
generateAIContextFiles: generateAIContextFilesMock,
|
|
refreshBaseRefLine: refreshBaseRefLineMock,
|
|
}));
|
|
vi.mock('../../src/cli/skill-gen.js', () => ({ generateSkillFiles: generateSkillFilesMock }));
|
|
vi.mock('../../src/cli/cli-message.js', () => ({ cliError: cliErrorMock }));
|
|
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|
closeLbug: vi.fn(async () => undefined),
|
|
closeLbugBeforeExit: vi.fn(async () => undefined),
|
|
isLbugReady: vi.fn(() => false),
|
|
}));
|
|
|
|
vi.mock('../../src/storage/repo-manager.js', () => ({
|
|
getStoragePaths: vi.fn((repoPath: string) => ({
|
|
storagePath: path.join(repoPath, '.gitnexus'),
|
|
lbugPath: path.join(repoPath, '.gitnexus', 'lbug'),
|
|
})),
|
|
getGlobalRegistryPath: vi.fn(() => 'registry.json'),
|
|
RegistryNameCollisionError: class RegistryNameCollisionError extends Error {},
|
|
AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {},
|
|
assertAnalysisFinalized: vi.fn(async () => undefined),
|
|
}));
|
|
|
|
// hasGitDir true; getGitRoot is unused because tests pass an explicit path.
|
|
vi.mock('../../src/storage/git.js', () => ({
|
|
getGitRoot: vi.fn((p: string) => p),
|
|
hasGitDir: vi.fn(() => true),
|
|
getDefaultBranch: getDefaultBranchMock,
|
|
}));
|
|
|
|
vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({
|
|
getMaxFileSizeBannerMessage: vi.fn(() => null),
|
|
}));
|
|
|
|
const upToDate = {
|
|
repoName: 'repo',
|
|
repoPath: '/repo',
|
|
stats: {},
|
|
alreadyUpToDate: true,
|
|
};
|
|
|
|
describe('analyzeCommand .gitnexusrc wiring (#243)', () => {
|
|
let dir: string;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
runFullAnalysisMock.mockReset();
|
|
runFullAnalysisMock.mockResolvedValue(upToDate);
|
|
generateAIContextFilesMock.mockReset();
|
|
generateAIContextFilesMock.mockResolvedValue({ files: [] });
|
|
refreshBaseRefLineMock.mockReset();
|
|
refreshBaseRefLineMock.mockResolvedValue({ files: [] });
|
|
generateSkillFilesMock.mockReset();
|
|
generateSkillFilesMock.mockResolvedValue({
|
|
skills: [{ name: 'c', label: 'Community', symbolCount: 1, fileCount: 1 }],
|
|
outputPath: '/repo/.claude/skills',
|
|
});
|
|
cliErrorMock.mockReset();
|
|
getDefaultBranchMock.mockReset();
|
|
getDefaultBranchMock.mockReturnValue(null);
|
|
process.exitCode = undefined;
|
|
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
|
|
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-rc-wire-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
const writeRc = (obj: unknown) =>
|
|
fs.writeFile(path.join(dir, '.gitnexusrc'), JSON.stringify(obj));
|
|
|
|
it('maps .gitnexusrc skipContextFiles to skipAgentsMd without implying skipSkills', async () => {
|
|
await writeRc({ skipContextFiles: true });
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, {});
|
|
|
|
expect(runFullAnalysisMock).toHaveBeenCalledTimes(1);
|
|
const opts = runFullAnalysisMock.mock.calls[0][1];
|
|
expect(opts.skipAgentsMd).toBe(true);
|
|
expect(opts.skipSkills).toBeFalsy();
|
|
});
|
|
|
|
it('indexOnly from config remains stronger than context/skills options', async () => {
|
|
await writeRc({ indexOnly: true });
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, {});
|
|
|
|
const opts = runFullAnalysisMock.mock.calls[0][1];
|
|
expect(opts.skipAgentsMd).toBe(true);
|
|
expect(opts.skipSkills).toBe(true);
|
|
});
|
|
|
|
it('uses .gitnexusrc defaultBranch for generated context', async () => {
|
|
await writeRc({ defaultBranch: 'develop' });
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, {});
|
|
|
|
const opts = runFullAnalysisMock.mock.calls[0][1];
|
|
expect(opts.defaultBranch).toBe('develop');
|
|
// A configured branch must short-circuit auto-detection.
|
|
expect(getDefaultBranchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('lets --default-branch override .gitnexusrc defaultBranch', async () => {
|
|
await writeRc({ defaultBranch: 'develop' });
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, { defaultBranch: 'cli-branch' });
|
|
|
|
const opts = runFullAnalysisMock.mock.calls[0][1];
|
|
expect(opts.defaultBranch).toBe('cli-branch');
|
|
});
|
|
|
|
it('auto-detects the default branch when neither CLI nor config set it', async () => {
|
|
// No .gitnexusrc on disk.
|
|
getDefaultBranchMock.mockReturnValue('trunk');
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, {});
|
|
|
|
const opts = runFullAnalysisMock.mock.calls[0][1];
|
|
expect(getDefaultBranchMock).toHaveBeenCalledTimes(1);
|
|
expect(opts.defaultBranch).toBe('trunk');
|
|
});
|
|
|
|
it('fails before analysis on an invalid .gitnexusrc, with an actionable error', async () => {
|
|
await fs.writeFile(path.join(dir, '.gitnexusrc'), '{ broken json ');
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, {});
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runFullAnalysisMock).not.toHaveBeenCalled();
|
|
expect(cliErrorMock).toHaveBeenCalledWith(
|
|
expect.stringMatching(/\.gitnexusrc/),
|
|
expect.objectContaining({ recoveryHint: 'gitnexusrc-invalid' }),
|
|
);
|
|
});
|
|
|
|
it('threads the resolved branch into the --skills re-generation (does not revert to main)', async () => {
|
|
await writeRc({ defaultBranch: 'develop' });
|
|
runFullAnalysisMock.mockResolvedValueOnce({
|
|
repoName: 'repo',
|
|
repoPath: dir,
|
|
stats: { files: 1, nodes: 10, edges: 20, communities: 0, processes: 5 },
|
|
alreadyUpToDate: false,
|
|
pipelineResult: { communityResult: undefined },
|
|
});
|
|
|
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
|
|
try {
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, { skills: true });
|
|
|
|
expect(generateSkillFilesMock).toHaveBeenCalledTimes(1);
|
|
expect(generateAIContextFilesMock).toHaveBeenCalledTimes(1);
|
|
const aiCtxOpts = generateAIContextFilesMock.mock.calls[0]![5];
|
|
expect(aiCtxOpts).toMatchObject({ defaultBranch: 'develop' });
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
// ── #1996 tri-review hardening ─────────────────────────────────────
|
|
|
|
it('rejects an invalid --default-branch up front with a CLI-specific hint (#1996)', async () => {
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, { defaultBranch: 'bad branch' });
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runFullAnalysisMock).not.toHaveBeenCalled();
|
|
expect(cliErrorMock).toHaveBeenCalledWith(
|
|
expect.stringMatching(/--default-branch/),
|
|
expect.objectContaining({ recoveryHint: 'default-branch-invalid' }),
|
|
);
|
|
});
|
|
|
|
it('does not auto-detect the branch when config skips context generation (#1996)', async () => {
|
|
await writeRc({ skipAgentsMd: true });
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, {});
|
|
|
|
// willGenerateContext=false ⇒ no git call for the (unused) branch.
|
|
expect(getDefaultBranchMock).not.toHaveBeenCalled();
|
|
expect(runFullAnalysisMock.mock.calls[0][1].skipAgentsMd).toBe(true);
|
|
});
|
|
|
|
it('refreshes base_ref in place on the alreadyUpToDate fast path (#1996 P2)', async () => {
|
|
await writeRc({ defaultBranch: 'develop' });
|
|
// Default mock returns alreadyUpToDate:true.
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, {});
|
|
|
|
expect(refreshBaseRefLineMock).toHaveBeenCalledTimes(1);
|
|
expect(refreshBaseRefLineMock).toHaveBeenCalledWith(dir, 'develop', expect.any(Object));
|
|
});
|
|
|
|
it('threads .gitnexusrc process-detection knobs and lets CLI win (AE3)', async () => {
|
|
await writeRc({ maxProcesses: '40', maxEntryPointCandidates: 300 });
|
|
const { analyzeCommand } = await import('../../src/cli/analyze.js');
|
|
|
|
await analyzeCommand(dir, { maxProcesses: '25' });
|
|
|
|
expect(runFullAnalysisMock.mock.calls[0][1]).toEqual(
|
|
expect.objectContaining({
|
|
maxProcesses: 25,
|
|
maxEntryPointCandidates: 300,
|
|
}),
|
|
);
|
|
});
|
|
});
|