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 ts from 'typescript'; import { afterEach, describe, expect, it } from 'vitest'; 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 staticStringValue(node: ts.Node | undefined): string | undefined { if (!node) return undefined; if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { const left = staticStringValue(node.left); const right = staticStringValue(node.right); if (left !== undefined && right !== undefined) return `${left}${right}`; } return undefined; } function extractRegisteredHelpDescriptions(): string[] { const descriptions = new Set(); 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 sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true); function visit(node: ts.Node): void { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const method = node.expression.name.text; const description = method === 'description' ? staticStringValue(node.arguments[0]) : method === 'option' || method === 'requiredOption' ? staticStringValue(node.arguments[1]) : undefined; if (description && /[A-Za-z]/.test(description)) { descriptions.add(description.replace(/\s+/g, ' ').trim()); } } ts.forEachChild(node, visit); } visit(sourceFile); } 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').choices(['fast', 'safe'])); command.addOption(new Option('--limit ', 'Limit').default('5')); command.addOption(new Option('--level [name]', 'Level').preset('auto')); command.addOption(new Option('--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 目标仓库(仅有一个已索引仓库时可省略)'); expect(result.stdout).toContain('-l, --limit 最多返回的流程数(默认: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 '); }); 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('当参数和对应环境变量同时提供时,参数优先。'); expect(result.stdout).toContain('提示:`.gitnexusignore` 支持 `.gitignore` 风格的取反。'); expect(result.stdout).not.toContain('Environment variables:'); expect(result.stdout).not.toContain('Flags override the corresponding env vars'); }); 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('-/'); }); 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 '); expect(result.stdout).toContain('--goal '); 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 '); expect(result.stdout).toContain('--file '); }); it('impact help keeps repo, include-tests, and disambiguation flags', () => { const result = runHelp('impact'); expect(result.status).toBe(0); expect(result.stdout).toContain('--depth '); expect(result.stdout).toContain('--include-tests'); expect(result.stdout).toContain('--repo '); // Disambiguation flags (#1907) — mirror the context help test so a // missing-flag regression on impact is caught here too. expect(result.stdout).toContain('--uid '); expect(result.stdout).toContain('--file '); expect(result.stdout).toContain('--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 '); expect(result.stdout).toContain('--base-ref '); expect(result.stdout).toContain('--repo '); }); 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 '); } }); 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('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 '); 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 '); 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 '); 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'); }); });