mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +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>
489 lines
21 KiB
TypeScript
489 lines
21 KiB
TypeScript
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<typeof setTimeout> | undefined;
|
|
|
|
try {
|
|
await expect(
|
|
Promise.race([
|
|
readRepoControlFile(dir, GITNEXUS_RC_FILENAME),
|
|
new Promise<never>((_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<string, unknown>).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' });
|
|
});
|
|
});
|