mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +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-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix: add --limit i18n, negative guard, correct property paths, and zh-CN translations - Add i18n keys for context/impact/cypher/detect-changes --limit options - Add zh-CN translations for all 4 --limit option descriptions - Add Math.max(0, parseInt()) guard to prevent negative --limit - Fix ALL property path mismatches discovered by audit: - context: callers/callees → incoming.calls/outgoing.calls+accesses - impact: upstream/downstream → affected_processes/affected_modules/byDepth - cypher: rows → row_count cap (rows embedded in markdown string) - detect-changes: affected_flows → affected_processes - Change query command from required to optional positional arg with -q alias - Update @ladybugdb/core from ^0.16.1 to ^0.17.1 - Update typescript from ^5.4.5 to ^5.9.3 * test: add E2E tests for --limit flag across all 5 CLI commands Tests context, impact, cypher, detect-changes, and query with --limit 1, baseline comparison, and --limit 0 (falsy/no-op). detect-changes output is formatted text (not JSON), so those tests count symbol lines matching 'Type name -> filePath' pattern. 14 tests, all passing. No regressions in 6455 existing tests. * fix: address Copilot review feedback on --limit guards - Add Math.max(0, ...) guard to queryCommand limit parsing - Change if(limit) to if(limit !== undefined) in all 5 commands (prevents --limit 0 from being treated as falsy/no-op) - Make queryText parameter optional (Commander may pass undefined) - Fix usage error strings: --search to -q, --query (en + zh-CN) * chore(autofix): apply prettier + eslint fixes via /autofix command * fix(cli): centralize --limit parsing, slice cypher markdown, fix usage text Address PR review feedback on --limit handling: - Add a shared parseLimit() helper (Number.isInteger(n) && n > 0), used by all 5 tool commands. Non-numeric / 0 / negative --limit now means "no limit" instead of the `options.limit ? Math.max(0, parseInt(...)) : undefined` path, where a string like "abc" is truthy and yields NaN -> slice(0, NaN) -> the guardrail commands (impact/context/detect-changes) silently emptied results with exit 0. - cypher: slice the markdown table to --limit data rows so the reported row_count matches what is actually printed (was capping row_count while printing every row). - Fix query usage string: [search_query] (optional positional) and `--query <text>` invocation form, not the option-definition `-q, --query <search_query>` syntax (en + zh-CN). - Add an E2E regression test for non-numeric --limit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(cli): escape newlines in cypher markdown cells A multi-line cell value (e.g. a symbol's `content`) was rendered with raw newlines via String(v), so one logical row spanned multiple physical lines. That corrupts the markdown table and breaks `cypher --limit`'s line-based slice (it kept the wrong number of rows, often zero, while row_count over-claimed). Collapse newlines in formatCypherAsMarkdown so one physical line == one row; the existing CLI slice is now correct and the pre-existing un---limited corruption is fixed too. (#2310 review) * test(cli): de-vacuum the --limit truncation tests The truncation it()s used the repo-banned vacuous-pass pattern (early-return on status===null, assertions guarded by if(Array.isArray), bounds-only toBeLessThanOrEqual — DoD.md:82) against `validateInput`, which has only 1 caller, so context/impact/query --limit 1 compared 1>=1 and stayed green even if the slice were deleted. Rewrite with unconditional, exact assertions and target `logMessage` (2 callers, 4 processes) so the no-limit baseline truly exceeds the limit; detect-changes now mutates two real function bodies (two changed symbols). Adds a multi-line-cell cypher --limit regression. (#2310) * test(ci): run cli-limit-e2e in the cross-platform matrix The --limit E2E suite spawns the real CLI (child_process) but was not in SPAWN_CLI, so it ran only on Ubuntu — the cross-platform check only fails on listed-but-missing files, not the reverse (TESTING.md §Cross-platform). Register it so the --limit regression guard also runs on Windows/macOS, where path separators, CRLF and the formatted-output arrow differ. (#2310) * fix(cli): document impact --limit affected-list cap, drop dead byDepth re-slice `impact --limit` also caps affected_processes/modules, but the help only mentioned the per-depth cap — so JSON consumers reading the affected lists got a silently-truncated array. Update en + zh-CN + the command description to say so. Also remove the client-side byDepth re-slice: the backend already paginates byDepth to the same limit (paginationLimit = clamp(limit,1,10000), offset applied backend-side), so the client slice was a guaranteed no-op. (#2310) * fix(cli): reconcile detect-changes --limit summary, list, and overflow formatDetectChangesResult computed the "... and N more" overflow from the already---limit-sliced array length, so under `--limit` the header (true summary total), the listed rows, and the marker disagreed — e.g. "2 symbols" in the header but a list of 1 with no marker. Base the overflow on the true summary.changed_count / affected_count instead, and add the same marker to the affected-processes list, so header + list + marker stay consistent. (#2310) * feat(cli): add -l shorthand to impact --limit The PR added the -l alias to context/cypher/detect-changes but left impact on the long --limit only, so `impact -l 5` errored while `context -l 5` worked. Add -l for parity and update the help-i18n OPTION_DESCRIPTION_KEYS key to the new `-l, --limit <n>` flag string so the description still resolves. (#2310) * fix(cli): bound all context --limit array categories context --limit sliced only incoming.calls / outgoing.calls / outgoing.accesses / processes, leaving the other relType buckets unbounded — notably incoming.accesses (bounded on outgoing but not incoming) plus imports/extends/ uses/… and typed_properties. Replace the hardcoded slices with a generic loop over every array-valued bucket under incoming/outgoing, plus typed_properties and processes, so --limit caps the whole context payload. (#2310) * refactor(cli): parse --offset with a parseLimit-style helper impactCommand parsed --offset with the legacy parseInt/Number.isFinite idiom while --limit had moved to parseLimit, leaving two parsing styles side by side. Add a sibling parseOffset helper (non-negative — offset 0 is valid) and use it, so both options share one idiom; as a bonus it now rejects negative/fractional offsets instead of forwarding them to the backend. (#2310) --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Gergo Magyar <gergomagyar@icloud.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import { spawnSync } from 'node:child_process';
|
||
import fs from 'node:fs';
|
||
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 { 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, '../..');
|
||
const cliEntry = path.join(repoRoot, 'src/cli/index.ts');
|
||
|
||
function runHelp(command: string, env: NodeJS.ProcessEnv = {}) {
|
||
return runHelpArgs([command], env);
|
||
}
|
||
|
||
function runHelpArgs(args: string[], env: NodeJS.ProcessEnv = {}) {
|
||
return spawnSync(process.execPath, ['--import', 'tsx', cliEntry, ...args, '--help'], {
|
||
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'],
|
||
['clean'],
|
||
['remove'],
|
||
['wiki'],
|
||
['augment'],
|
||
['publish'],
|
||
['query'],
|
||
['context'],
|
||
['impact'],
|
||
['cypher'],
|
||
['detect-changes'],
|
||
['eval-server'],
|
||
['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<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 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>', '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');
|
||
expect(result.stdout).toContain('一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 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('当参数和对应环境变量同时提供时,参数优先。');
|
||
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('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('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('--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');
|
||
});
|
||
});
|