GitNexus/gitnexus/test/unit/cli-index-help.test.ts
Gergő Magyar ba39d5c009
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 (#3324)
* 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>
2026-09-18 13:37:15 +01:00

430 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Command, Option } from 'commander';
import * as t from '@babel/types';
import { afterEach, describe, expect, it } from 'vitest';
import {
forEachChild,
parseTypeScript,
staticMemberName,
staticStringValue,
} from '../helpers/parse-typescript-source.js';
import { CLI_SPAWN_PREFIX } from '../helpers/cli-entry.js';
import { localizeCliHelp } from '../../src/cli/help-i18n.js';
import { setCliLanguage, type SupportedCliLanguage } from '../../src/cli/i18n/index.js';
const testDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(testDir, '../..');
function runHelp(command: string, env: NodeJS.ProcessEnv = {}) {
return runHelpArgs([command], env);
}
function runHelpArgs(args: string[], env: NodeJS.ProcessEnv = {}) {
return runCliArgs([...args, '--help'], env);
}
function runCliArgs(args: string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync(process.execPath, [...CLI_SPAWN_PREFIX, ...args], {
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, ...env },
});
}
function runRootHelp(env: NodeJS.ProcessEnv = {}) {
return runHelpArgs([], env);
}
const allHelpCommands = [
[],
['setup'],
['analyze'],
['index'],
['serve'],
['mcp'],
['list'],
['status'],
['doctor'],
['update'],
['clean'],
['remove'],
['wiki'],
['augment'],
['publish'],
['query'],
['context'],
['impact'],
['cypher'],
['detect-changes'],
['eval-server'],
['embeddings'],
['embeddings', 'install'],
['embeddings', 'sync'],
['group'],
['group', 'create'],
['group', 'add'],
['group', 'remove'],
['group', 'list'],
['group', 'status'],
['group', 'sync'],
['group', 'impact'],
['group', 'query'],
['group', 'contracts'],
];
function extractRegisteredHelpDescriptions(): string[] {
const descriptions = new Set<string>();
const sourceFiles = ['src/cli/index.ts', 'src/cli/group.ts'];
for (const relativePath of sourceFiles) {
const filePath = path.join(repoRoot, relativePath);
const source = fs.readFileSync(filePath, 'utf8');
const { ast } = parseTypeScript(filePath, source);
function visit(node: t.Node): void {
if (
t.isCallExpression(node) &&
(t.isMemberExpression(node.callee) || t.isOptionalMemberExpression(node.callee))
) {
const method = staticMemberName(node.callee);
let description: string | undefined;
if (method === 'description') {
description = staticStringValue(node.arguments[0]);
} else if (method === 'option' || method === 'requiredOption') {
description = staticStringValue(node.arguments[1]);
}
if (description && /[A-Za-z]/.test(description)) {
descriptions.add(description.replace(/\s+/g, ' ').trim());
}
}
forEachChild(node, visit);
}
visit(ast);
}
return [...descriptions].filter((description) => description.length > 0).sort();
}
function metadataHelp(language: SupportedCliLanguage) {
setCliLanguage(language);
const command = new Command('probe');
command.addOption(new Option('--mode <mode>', 'Mode').choices(['fast', 'safe']));
command.addOption(new Option('--limit <n>', 'Limit').default('5'));
command.addOption(new Option('--level [name]', 'Level').preset('auto'));
command.addOption(new Option('--token <token>', 'Token').env('GITNEXUS_TOKEN'));
localizeCliHelp(command);
return command.helpInformation();
}
describe('CLI help surface', () => {
afterEach(() => setCliLanguage(null));
it('root help localizes commander headings, options, and command descriptions', () => {
const result = runRootHelp({ GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status).toBe(0);
expect(result.stdout).toContain('用法: gitnexus [options] [command]');
expect(result.stdout).toContain('GitNexus 本地 CLI 和 MCP 服务器');
expect(result.stdout).toContain('选项:');
expect(result.stdout).toContain('-V, --version 输出版本号');
expect(result.stdout).toContain('-h, --help 显示命令帮助');
expect(result.stdout).toContain('命令:');
expect(result.stdout).toContain('setup');
// Stable fragments rather than the full editor roster: the roster grows
// over time (see PR #2368), and the dynamic test below ("localizes every
// registered CLI command...") already fails on any untranslated
// description, so freezing the roster here only creates churn.
expect(result.stdout).toContain('一次性设置');
expect(result.stdout).toContain('配置 MCP');
expect(result.stdout).toContain('detect-changes|detect_changes [options]');
expect(result.stdout).toContain('将 git diff hunk 映射到已索引符号和受影响执行流程');
expect(result.stdout).not.toContain('GitNexus local CLI and MCP server');
expect(result.stdout).not.toContain('display help for command');
});
it('command help localizes option descriptions and help suffix text', () => {
const result = runHelp('query', { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status).toBe(0);
expect(result.stdout).toContain('用法: gitnexus query [options] [search_query]');
expect(result.stdout).toContain('搜索知识图谱中与概念相关的执行流程');
expect(result.stdout).toContain('-r, --repo <name> 目标仓库(仅有一个已索引仓库时可省略)');
expect(result.stdout).toContain('-l, --limit <n> 最多返回的流程数(默认:5)');
expect(result.stdout).toContain('-h, --help 显示命令帮助');
expect(result.stdout).not.toContain('Target repository (omit if only one indexed)');
});
it('setup help exposes selective coding-agent configuration', () => {
const result = runHelp('setup');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus setup [options]');
expect(result.stdout).toContain('-c, --coding-agent <agents>');
});
it('localizes every registered CLI command and option description in zh-CN help', () => {
const zhHelpOutput = allHelpCommands
.map((args) => {
const result = runHelpArgs(args, { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status, `gitnexus ${args.join(' ')} --help`).toBe(0);
return result.stdout;
})
.join('\n');
const untranslated = extractRegisteredHelpDescriptions().filter((description) =>
zhHelpOutput.includes(description),
);
expect(untranslated).toEqual([]);
});
it('analyze help localizes custom environment variable help text', () => {
const result = runHelp('analyze', { GITNEXUS_LANG: 'zh-CN' } as NodeJS.ProcessEnv);
expect(result.status).toBe(0);
expect(result.stdout).toContain('环境变量:');
expect(result.stdout).toContain('GITNEXUS_STORAGE_PATH=/absolute/index');
expect(result.stdout).toContain('完整外部索引目录');
expect(result.stdout).toContain('GITNEXUS_STORAGE_ROOT=/absolute/root');
expect(result.stdout).toContain('外部索引根目录');
expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full');
expect(result.stdout).toContain('源码文本保留策略');
expect(result.stdout).toContain(
'CLI 参数优先于 `.gitnexusrc`,后者优先于环境变量,环境变量优先于内置默认值。',
);
expect(result.stdout).toContain('提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。');
expect(result.stdout).not.toContain('Environment variables:');
expect(result.stdout).not.toContain('Flags override the corresponding env vars');
expect(result.stdout).not.toContain('当参数和对应环境变量同时提供时,参数优先。');
});
it('analyze help documents the external storage root layout', () => {
const result = runHelp('analyze');
expect(result.status).toBe(0);
expect(result.stdout).toContain('GITNEXUS_STORAGE_PATH=/absolute/index');
expect(result.stdout).toContain('Complete external index directory');
expect(result.stdout).toContain('GITNEXUS_STORAGE_ROOT=/absolute/root');
expect(result.stdout).toContain('External index root');
expect(result.stdout).toContain('GITNEXUS_CONTENT_RETENTION=full');
expect(result.stdout).toContain('Source-text retention profile');
expect(result.stdout).toContain('<repo-basename>-<canonical-path-hash>/');
expect(result.stdout).toContain(
'CLI flags take precedence over `.gitnexusrc`, which takes precedence over env vars, which take precedence over built-in defaults.',
);
expect(result.stdout).not.toContain('Flags override the corresponding env vars');
});
it('query help keeps advanced search options without importing analyze deps', () => {
const result = runHelp('query');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--context <text>');
expect(result.stdout).toContain('--goal <text>');
expect(result.stdout).toContain('--content');
expect(result.stderr).not.toContain('tree-sitter-kotlin');
});
it('context help keeps optional name and disambiguation flags', () => {
const result = runHelp('context');
expect(result.status).toBe(0);
expect(result.stdout).toContain('context [options] [name]');
expect(result.stdout).toContain('--uid <uid>');
expect(result.stdout).toContain('--file <path>');
});
it('impact help keeps repo, include-tests, and disambiguation flags', () => {
const result = runHelp('impact');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--depth <n>');
expect(result.stdout).toContain('--include-tests');
expect(result.stdout).toContain('--repo <name>');
// Disambiguation flags (#1907) — mirror the context help test so a
// missing-flag regression on impact is caught here too.
expect(result.stdout).toContain('--uid <uid>');
expect(result.stdout).toContain('--file <path>');
expect(result.stdout).toContain('--kind <kind>');
});
it('detect-changes help exposes compare scope and base-ref flags', () => {
const result = runHelp('detect-changes');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus detect-changes|detect_changes [options]');
expect(result.stdout).toContain('--scope <scope>');
expect(result.stdout).toContain('--base-ref <ref>');
expect(result.stdout).toContain('--repo <name>');
});
it('query-family commands expose the --branch scope flag (#2106)', () => {
for (const cmd of ['query', 'context', 'impact', 'cypher', 'detect-changes']) {
const result = runHelp(cmd);
expect(result.status, cmd).toBe(0);
expect(result.stdout, cmd).toContain('--branch <name>');
}
});
it('auto-sync help exposes lifecycle actions and state files', () => {
const result = runHelp('auto-sync');
expect(result.status).toBe(0);
expect(result.stdout).toContain('gitnexus auto-sync [options] [action]');
expect(result.stdout).toContain('Actions: init, start (default), restart, stop, status, reset');
expect(result.stdout).toContain('GITNEXUS_HOME/watch_config.yml');
expect(result.stdout).toContain('GITNEXUS_HOME/watch/watch.pid');
expect(result.stdout).toContain('GITNEXUS_HOME/watch/project_commit_info.txt');
});
it('watch is reserved and does not start auto-sync or local watch', () => {
const help = runHelp('watch');
expect(help.status).toBe(0);
expect(help.stdout).toContain('gitnexus watch [options] [action]');
expect(help.stdout).toContain('gitnexus analyze --watch');
expect(help.stdout).toContain('gitnexus auto-sync start');
expect(help.stdout).not.toContain('GITNEXUS_HOME/watch_config.yml');
const started = runCliArgs(['watch'], {});
expect(started.status).toBe(1);
expect(started.stderr).toContain('gitnexus watch');
expect(started.stderr).toContain('gitnexus analyze --watch');
expect(started.stderr).toContain('gitnexus auto-sync start');
const startAction = runCliArgs(['watch', 'start'], {});
expect(startAction.status).toBe(1);
expect(startAction.stderr).toContain('gitnexus auto-sync start');
});
it('auto-sync init creates the default watch_config.yml and does not overwrite it', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-init-'));
try {
const first = runCliArgs(['auto-sync', 'init'], { GITNEXUS_HOME: home });
const configPath = path.join(home, 'watch_config.yml');
expect(first.status).toBe(0);
expect(first.stdout).toContain(`Created ${configPath}`);
const config = fs.readFileSync(configPath, 'utf8');
expect(config).toContain('sync_interval_minutes: 10');
expect(config).toContain('analyze_failure_threshold: 3');
expect(config).toContain('analyze_timeout: 5m');
expect(config).toContain('pdg: false');
expect(config).toContain('omit = preserve live index mode');
expect(config).toContain('overwrite_local_changes: false');
expect(config).toContain(`local_path: ${path.join(home, 'repos')}`);
expect(config).not.toContain('/abs/path/to/repos');
expect(config).toContain('git@github.com:owner/repo.git');
expect(config).not.toContain('group_name:');
const second = runCliArgs(['auto-sync', 'init'], { GITNEXUS_HOME: home });
expect(second.status).toBe(1);
expect(second.stderr).toContain(`Config already exists: ${configPath}`);
expect(fs.readFileSync(configPath, 'utf8')).toBe(config);
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('auto-sync reset removes only derived auto-sync state files', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-reset-'));
const watchDir = path.join(home, 'watch');
const cloneMarker = path.join(home, 'repos', 'repo', 'keep.txt');
try {
fs.mkdirSync(path.dirname(cloneMarker), { recursive: true });
fs.writeFileSync(cloneMarker, 'keep');
fs.mkdirSync(watchDir, { recursive: true });
fs.writeFileSync(path.join(watchDir, 'auto-sync-state.json'), '{}');
fs.writeFileSync(path.join(watchDir, 'project_commit_info.txt'), 'derived');
const result = runCliArgs(['auto-sync', 'reset'], { GITNEXUS_HOME: home });
expect(result.status).toBe(0);
expect(result.stdout).toContain('Reset analysis state');
expect(fs.existsSync(path.join(watchDir, 'auto-sync-state.json'))).toBe(false);
expect(fs.existsSync(path.join(watchDir, 'project_commit_info.txt'))).toBe(false);
expect(fs.readFileSync(cloneMarker, 'utf8')).toBe('keep');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('auto-sync stop exits non-zero when no watch was stopped', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-stop-'));
try {
const result = runCliArgs(['auto-sync', 'stop'], { GITNEXUS_HOME: home });
expect(result.status).toBe(1);
expect(result.stderr).toContain('Watch is not running');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('auto-sync restart starts when the watch is not running', () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-watch-restart-'));
try {
const result = runCliArgs(['auto-sync', 'restart'], { GITNEXUS_HOME: home });
expect(result.status).toBe(1);
expect(result.stderr).toContain('Watch is not running');
expect(result.stderr).toContain('Missing config file');
} finally {
fs.rmSync(home, { recursive: true, force: true });
}
});
it('wiki help shows provider, review, and verbose flags', () => {
const result = runHelp('wiki');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--provider <provider>');
expect(result.stdout).toContain('claude');
expect(result.stdout).toContain('codex');
expect(result.stdout).toContain('grok');
expect(result.stdout).toContain('--review');
expect(result.stdout).toContain('-v, --verbose');
expect(result.stdout).toContain('--model <model>');
expect(result.stdout).toContain('--gist');
});
it('publish help names the registry, the token env var, and the opt-out behaviour', () => {
const result = runHelp('publish');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--id <owner/repo>');
expect(result.stdout).toContain('--skip-git');
// Discoverability contract: a contributor scanning `--help` must see
// (a) which registry this dispatches to, and (b) the env var that
// gates the opt-in. Both are part of the no-token contract.
expect(result.stdout).toContain('understand-quickly');
expect(result.stdout).toContain('UNDERSTAND_QUICKLY_TOKEN');
});
it('analyze help includes the FTS repair option', () => {
const result = runHelp('analyze');
expect(result.status).toBe(0);
expect(result.stdout).toContain('--repair-fts');
});
it('localizes commander-generated option metadata labels', () => {
const english = metadataHelp('en');
const chinese = metadataHelp('zh-CN');
expect(english).toContain('choices: "fast", "safe"');
expect(english).toContain('default: "5"');
expect(english).toContain('preset: "auto"');
expect(english).toContain('env: GITNEXUS_TOKEN');
expect(chinese).toContain('可选值: "fast", "safe"');
expect(chinese).toContain('默认: "5"');
expect(chinese).toContain('预设: "auto"');
expect(chinese).toContain('环境变量: GITNEXUS_TOKEN');
});
});