GitNexus/gitnexus/test/unit/analyze-gitnexusrc.test.ts
Gergő Magyar 8a9b13fc3b
feat(cli): add .gitnexusrc config and --default-branch for analyze (#243) (#1996)
* feat(cli): add .gitnexusrc config and --default-branch for analyze (#243)

Let a repo preconfigure recurring `gitnexus analyze` options via a
project-local `.gitnexusrc` (JSON) plus a new `--default-branch` flag, so
projects on `develop`/`master` no longer get the generated regression
example rewritten to `base_ref: "main"` on every analyze run.

- New `cli/analyze-config.ts`: locate/parse/validate `.gitnexusrc` (flat +
  nested `analyze` form, alias mapping, fail-closed on unknown keys / bad
  types / hidden chars), merge with CLI (CLI overrides config), and resolve
  the default branch (CLI > config defaultBranch/branch > auto-detected
  origin/HEAD > "main").
- `getDefaultBranch()` in storage/git.ts (best-effort, local-only, no network).
- Thread `defaultBranch` through analyze -> run-analyze -> ai-context so the
  generated regression-compare example uses the configured branch,
  JSON-escaped; the --skills re-generation path uses the same branch.
- `skipContextFiles`/`skipAiContext` alias `skipAgentsMd` (block only, does
  not imply skipSkills); `indexOnly` stays the stronger "skip all injection".
- README + CLI help; unit tests for the config module and end-to-end wiring
  tests that fail if config is parsed but not threaded into analyze/context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(cli): harden .gitnexusrc against Markdown injection and stale base_ref (#243)

Addresses the tri-review findings on PR #1996.

- P1 (Markdown injection into generated AGENTS.md/CLAUDE.md): reject the
  backtick in validateBranchName (covers --default-branch, .gitnexusrc, and the
  origin/HEAD auto-detect via sanitizeDetectedBranch) and strip it at the
  ai-context sink (markdownSafeBranch); reject Markdown-significant chars
  (` * [ ] < >) in the config `name` (it lands in generated bold/code-spans),
  while still allowing `_ . - /`. Corrected the false "can't break the code
  span" comment.
- P2 (configured defaultBranch silently no-ops on an up-to-date repo): on the
  alreadyUpToDate fast path, surgically refresh only the `base_ref:` line in
  AGENTS.md/CLAUDE.md (refreshBaseRefLine), preserving the rest of the block
  incl. --skills community rows; no-op when unchanged.
- P3: gate the .gitnexusrc key lookup with Object.hasOwn so inherited keys
  (__proto__, constructor, …) hit the actionable "Unknown key" error.
- Cleanups: strip a leading UTF-8 BOM before JSON.parse; give --default-branch
  CLI validation its own `default-branch-invalid` recovery hint; drop the dead
  `options.defaultBranch` write and the now-redundant `options?.` chaining.
- Tests: backtick rejection + even-backtick generated output, 255-char branch
  bound, config `name` Markdown rejection, __proto__ → Unknown key, BOM,
  mergeAnalyzeOptions omits defaultBranch, willGenerateContext suppression, and
  the fast-path base_ref refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:48:55 +01:00

236 lines
9 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/generated',
})),
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) }));
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/generated',
});
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));
});
});