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>
223 lines
9 KiB
TypeScript
223 lines
9 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { createWatchIgnorePredicate } from '../../src/config/ignore-service.js';
|
|
import { isRelevantWatchPath, resolveWatchOptions } from '../../src/cli/analyze-watch.js';
|
|
import * as git from '../../src/storage/git.js';
|
|
|
|
vi.mock('../../src/storage/git.js', () => ({
|
|
getCoreExcludesFilePath: vi.fn(),
|
|
getGitInfoExcludePath: vi.fn(),
|
|
}));
|
|
|
|
let repoPath: string;
|
|
|
|
beforeEach(async () => {
|
|
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-'));
|
|
vi.mocked(git.getCoreExcludesFilePath).mockReturnValue(null);
|
|
vi.mocked(git.getGitInfoExcludePath).mockReturnValue(null);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(repoPath, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('watch path selection', () => {
|
|
it('forwards explicit FTS opt-out without changing the default', async () => {
|
|
const names = [
|
|
'GITNEXUS_MAX_FILE_SIZE',
|
|
'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS',
|
|
'GITNEXUS_VERBOSE',
|
|
] as const;
|
|
for (const name of names) vi.stubEnv(name, process.env[name]);
|
|
const baseline = { maxFileSize: undefined, workerTimeout: undefined, verbose: undefined };
|
|
try {
|
|
expect((await resolveWatchOptions(repoPath, { skipFts: true }, baseline)).skipFts).toBe(true);
|
|
expect((await resolveWatchOptions(repoPath, {}, baseline)).skipFts).toBeUndefined();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
});
|
|
it('accepts every scanner-admitted file instead of maintaining a second allow-list', () => {
|
|
expect(isRelevantWatchPath('src/service.ts')).toBe(true);
|
|
expect(isRelevantWatchPath('server/app.py')).toBe(true);
|
|
expect(isRelevantWatchPath('backend/project.csproj')).toBe(true);
|
|
expect(isRelevantWatchPath('.gitnexusrc')).toBe(true);
|
|
expect(isRelevantWatchPath('README.md')).toBe(true);
|
|
expect(isRelevantWatchPath('docs/guide.mdx')).toBe(true);
|
|
expect(isRelevantWatchPath('config/application-prod.yml')).toBe(true);
|
|
expect(isRelevantWatchPath('src/main/resources/application.properties')).toBe(true);
|
|
expect(isRelevantWatchPath('templates/page.html')).toBe(true);
|
|
expect(isRelevantWatchPath('templates/page.htm')).toBe(true);
|
|
expect(isRelevantWatchPath('views/page.ejs')).toBe(true);
|
|
expect(isRelevantWatchPath('views/page.hbs')).toBe(true);
|
|
expect(isRelevantWatchPath('views/page.blade.php')).toBe(true);
|
|
expect(
|
|
isRelevantWatchPath(
|
|
'src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports',
|
|
),
|
|
).toBe(true);
|
|
expect(isRelevantWatchPath('src/main/resources/META-INF/spring.factories')).toBe(true);
|
|
expect(isRelevantWatchPath('tsconfig.base.json')).toBe(true);
|
|
expect(isRelevantWatchPath('packages/api/tsconfig.build.json')).toBe(true);
|
|
expect(isRelevantWatchPath('schema.sql')).toBe(true);
|
|
expect(isRelevantWatchPath('Dockerfile')).toBe(true);
|
|
expect(isRelevantWatchPath('assets/logo.png')).toBe(true);
|
|
expect(isRelevantWatchPath('../outside.ts')).toBe(false);
|
|
expect(isRelevantWatchPath('C:\\outside.ts')).toBe(false);
|
|
});
|
|
|
|
it('honors hardcoded, gitignore, and explicit-unignore rules', async () => {
|
|
await fs.writeFile(
|
|
path.join(repoPath, '.gitignore'),
|
|
['generated/*', '!generated/', '!generated/keep.ts'].join('\n'),
|
|
);
|
|
const ignored = await createWatchIgnorePredicate(repoPath);
|
|
|
|
expect(ignored(path.join(repoPath, 'node_modules', 'pkg', 'index.ts'))).toBe(true);
|
|
expect(ignored(path.join(repoPath, 'generated'), true)).toBe(false);
|
|
expect(ignored(path.join(repoPath, 'generated', 'drop.ts'))).toBe(true);
|
|
expect(ignored(path.join(repoPath, 'generated', 'keep.ts'))).toBe(false);
|
|
expect(ignored(path.join(repoPath, 'src', 'keep.ts'))).toBe(false);
|
|
expect(ignored(path.resolve(repoPath, '..', 'outside.ts'))).toBe(true);
|
|
});
|
|
|
|
it('does not partially mutate environment state when a reloaded config is invalid', async () => {
|
|
const names = [
|
|
'GITNEXUS_MAX_FILE_SIZE',
|
|
'GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS',
|
|
'GITNEXUS_VERBOSE',
|
|
] as const;
|
|
const original = Object.fromEntries(names.map((name) => [name, process.env[name]]));
|
|
try {
|
|
await fs.writeFile(
|
|
path.join(repoPath, '.gitnexusrc'),
|
|
JSON.stringify({ maxFileSize: '2048', workerTimeout: '90', workers: '2' }),
|
|
);
|
|
const baseline = { maxFileSize: '512', workerTimeout: '30000', verbose: undefined };
|
|
await resolveWatchOptions(repoPath, {}, baseline);
|
|
expect(process.env.GITNEXUS_MAX_FILE_SIZE).toBe('2048');
|
|
expect(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS).toBe('90000');
|
|
|
|
await fs.writeFile(
|
|
path.join(repoPath, '.gitnexusrc'),
|
|
JSON.stringify({ maxFileSize: '4096', workerTimeout: '120', workers: '0' }),
|
|
);
|
|
await expect(resolveWatchOptions(repoPath, {}, baseline)).rejects.toThrow(
|
|
'--workers must be a positive integer',
|
|
);
|
|
expect(process.env.GITNEXUS_MAX_FILE_SIZE).toBe('2048');
|
|
expect(process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS).toBe('90000');
|
|
} finally {
|
|
for (const name of names) {
|
|
const value = original[name];
|
|
if (value === undefined) delete process.env[name];
|
|
else process.env[name] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
it('ignores unsupported repository defaults but rejects explicit unsupported CLI flags', async () => {
|
|
await fs.writeFile(
|
|
path.join(repoPath, '.gitnexusrc'),
|
|
JSON.stringify({
|
|
embeddings: true,
|
|
defaultBranch: 'develop',
|
|
skipAgentsMd: false,
|
|
skipSkills: false,
|
|
stats: true,
|
|
springActuator: './actuator',
|
|
}),
|
|
);
|
|
const ignored: string[][] = [];
|
|
await expect(
|
|
resolveWatchOptions(
|
|
repoPath,
|
|
{},
|
|
{
|
|
maxFileSize: undefined,
|
|
workerTimeout: undefined,
|
|
verbose: undefined,
|
|
},
|
|
(names) => ignored.push([...names]),
|
|
),
|
|
).resolves.toMatchObject({ skipAgentsMd: true, skipSkills: true });
|
|
expect(ignored).toEqual([
|
|
['embeddings', 'defaultBranch', 'skipAgentsMd', 'skipSkills', 'stats', 'springActuator'],
|
|
]);
|
|
|
|
const unsupportedCliOptions: Array<[Parameters<typeof resolveWatchOptions>[1], string]> = [
|
|
[{ embeddings: true }, '--embeddings'],
|
|
[{ defaultBranch: 'develop' }, '--default-branch'],
|
|
[{ skipAgentsMd: true }, '--skip-agents-md'],
|
|
[{ skipSkills: true }, '--skip-skills'],
|
|
[{ stats: false }, '--no-stats'],
|
|
[{ springActuator: './actuator' }, '--spring-actuator'],
|
|
// Rejected for the same reason as the Actuator path: the watcher reacts
|
|
// to source changes and nothing watches a document directory, so
|
|
// accepting the flag would read the documents once and then serve a
|
|
// stale answer for the rest of the session.
|
|
[{ asyncapiSpec: './docs/asyncapi' }, '--asyncapi-spec'],
|
|
];
|
|
for (const [options, flag] of unsupportedCliOptions) {
|
|
await expect(
|
|
resolveWatchOptions(repoPath, options, {
|
|
maxFileSize: undefined,
|
|
workerTimeout: undefined,
|
|
verbose: undefined,
|
|
}),
|
|
).rejects.toThrow(`analyze --watch does not support ${flag}`);
|
|
}
|
|
});
|
|
|
|
it('applies process-detection budget keys from rc and CLI without throwing (#3313)', async () => {
|
|
await fs.writeFile(
|
|
path.join(repoPath, '.gitnexusrc'),
|
|
JSON.stringify({ maxProcesses: '40', maxEntryPointCandidates: 400 }),
|
|
);
|
|
const baseline = { maxFileSize: undefined, workerTimeout: undefined, verbose: undefined };
|
|
await expect(resolveWatchOptions(repoPath, {}, baseline)).resolves.toMatchObject({
|
|
maxProcesses: 40,
|
|
maxEntryPointCandidates: 400,
|
|
});
|
|
await expect(
|
|
resolveWatchOptions(repoPath, { maxProcesses: '25' }, baseline),
|
|
).resolves.toMatchObject({
|
|
maxProcesses: 25,
|
|
maxEntryPointCandidates: 400,
|
|
});
|
|
const zeroBudget = await resolveWatchOptions(repoPath, { maxProcesses: '0' }, baseline);
|
|
expect(zeroBudget).toMatchObject({ maxEntryPointCandidates: 400 });
|
|
expect(zeroBudget.maxProcesses).toBeUndefined();
|
|
});
|
|
|
|
it('rejects a watch file-size threshold above the parser ceiling', async () => {
|
|
await expect(
|
|
resolveWatchOptions(
|
|
repoPath,
|
|
{ maxFileSize: '32769' },
|
|
{
|
|
maxFileSize: undefined,
|
|
workerTimeout: undefined,
|
|
verbose: undefined,
|
|
},
|
|
),
|
|
).rejects.toThrow('maxFileSize must not exceed 32768');
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'rejects repository ignore files that are final-file symlinks',
|
|
async () => {
|
|
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-watch-outside-'));
|
|
try {
|
|
const target = path.join(outside, 'ignore');
|
|
await fs.writeFile(target, 'secret.ts\n');
|
|
await fs.symlink(target, path.join(repoPath, '.gitignore'), 'file');
|
|
await expect(createWatchIgnorePredicate(repoPath)).rejects.toThrow(/symbolic link/);
|
|
} finally {
|
|
await fs.rm(outside, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
});
|