From 5e6b79deba67bc521a58350d586dcb6ea89c2f9d Mon Sep 17 00:00:00 2001 From: Kevin Rajan <7121943+kvnloo@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:59:53 -0500 Subject: [PATCH] fix(cli): do not call zero-symbol detect-changes a clean tree (#3138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): do not call zero-symbol detect-changes a clean tree Print backend summary.message and distinguish a parsed diff with no overlapping indexed symbols from an empty git diff. Pin --color=never so color.ui=always cannot hide +++ b/ headers. * fix(cli): localize clean-tree detect-changes and skip no-overlap when partial Production empty diffs carry English summary.message; route that through t() so zh-CN fires. Do not claim no indexed-symbol overlap on queryDegraded partial results. Pin formatter tests to en and cover the production payload shapes. * fix(cli): prettier detect-changes-format and degraded eval assertion --------- Co-authored-by: Gergő Magyar --- gitnexus/src/cli/detect-changes-format.ts | 24 +++++ gitnexus/src/cli/i18n/en.ts | 2 + gitnexus/src/cli/i18n/zh-CN.ts | 2 + gitnexus/src/mcp/local/local-backend.ts | 3 + gitnexus/test/unit/detect-changes-eol.test.ts | 3 +- .../test/unit/detect-changes-format.test.ts | 92 +++++++++++++++++++ gitnexus/test/unit/eval-formatters.test.ts | 3 +- 7 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 gitnexus/test/unit/detect-changes-format.test.ts diff --git a/gitnexus/src/cli/detect-changes-format.ts b/gitnexus/src/cli/detect-changes-format.ts index 98ecffa3e..9e91cc330 100644 --- a/gitnexus/src/cli/detect-changes-format.ts +++ b/gitnexus/src/cli/detect-changes-format.ts @@ -6,6 +6,7 @@ type DetectChangesSummary = { changed_count?: number; affected_count?: number; risk_level?: string; + message?: string; }; type ChangedSymbol = { @@ -55,6 +56,29 @@ export function formatDetectChangesResult(result: unknown): string { ); if ((summary.changed_count ?? 0) === 0) { + // Parse-fail payloads set `partial` and an honest `message` (#2915/#3131). + // Production *clean* trees also set English `message: 'No changes detected.'` + // — that must go through `t('tool.detectChanges.noChanges')` or zh-CN never + // fires. Only pass the backend string through on a degraded/parse-fail run. + if ( + payload.partial && + typeof summary.message === 'string' && + summary.message.trim().length > 0 + ) { + return [...notes, summary.message.trim()].join('\n'); + } + // Confirmed no-overlap: files parsed, mapping succeeded, zero symbols. + // `queryDegraded` is `partial: true` with the same counts and no message — + // do not call that a confirmed mapping (#3131 honesty). + if (!payload.partial && (summary.changed_files ?? 0) > 0) { + return [ + ...notes, + t('tool.detectChanges.noOverlappingSymbols', { files: summary.changed_files }), + ].join('\n'); + } + if (payload.partial) { + return notes.join('\n'); + } return [...notes, t('tool.detectChanges.noChanges')].join('\n'); } diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index aba8e78f0..c54f5707b 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -76,6 +76,8 @@ export const en = { 'tool.warn.unknownKind': "--kind '{{kind}}' is not a known symbol kind (e.g. Function, Class, Method); it will not narrow the result.", 'tool.detectChanges.noChanges': 'No changes detected.', + 'tool.detectChanges.noOverlappingSymbols': + 'Diff touched {{files}} file(s) but no indexed symbols overlap those hunks — not a clean tree.', 'tool.detectChanges.partial': 'PARTIAL RESULT: a graph query failed, so changed symbols may be missing. Do not read this as a clean pre-commit check.', 'tool.detectChanges.truncated': diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 5f3b52040..a8001af8a 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -78,6 +78,8 @@ export const zhCN = { 'tool.warn.unknownKind': "--kind '{{kind}}' 不是已知的符号类型(如 Function、Class、Method),不会用于缩小结果范围。", 'tool.detectChanges.noChanges': '未检测到变更。', + 'tool.detectChanges.noOverlappingSymbols': + 'diff 触及 {{files}} 个文件,但没有索引符号与这些 hunk 重叠 — 并非干净工作区。', 'tool.detectChanges.partial': '结果不完整:图查询失败,可能遗漏已变更符号。请勿将其视为通过的提交前检查。', 'tool.detectChanges.truncated': diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index a5a7b85ce..783cb1255 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -988,6 +988,9 @@ export function buildDetectChangesDiffArgs(scope: string, baseRef?: string): str 'diff', '--ignore-cr-at-eol', '--no-ext-diff', + // color.ui=always prefixes `+++ b/` with ANSI, so parseDiffHunks sees zero + // files and the CLI used to print a clean "No changes detected." (#3131). + '--color=never', '--src-prefix=a/', '--dst-prefix=b/', ]; diff --git a/gitnexus/test/unit/detect-changes-eol.test.ts b/gitnexus/test/unit/detect-changes-eol.test.ts index 9ed43a9b4..bb3da5328 100644 --- a/gitnexus/test/unit/detect-changes-eol.test.ts +++ b/gitnexus/test/unit/detect-changes-eol.test.ts @@ -8,11 +8,12 @@ import { parseDiffHunks } from '../../src/storage/git.js'; import { diffArgsFor } from '../helpers/detect-changes-diff-args.js'; import { commitAll, initGitRepo } from '../helpers/temp-git-repo.js'; -/** The five flags every scope carries, ahead of its own ref/staging arguments. */ +/** The six flags every scope carries, ahead of its own ref/staging arguments. */ const GUARD_FLAGS = [ 'diff', '--ignore-cr-at-eol', '--no-ext-diff', + '--color=never', '--src-prefix=a/', '--dst-prefix=b/', ]; diff --git a/gitnexus/test/unit/detect-changes-format.test.ts b/gitnexus/test/unit/detect-changes-format.test.ts new file mode 100644 index 000000000..18f21eeeb --- /dev/null +++ b/gitnexus/test/unit/detect-changes-format.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { formatDetectChangesResult } from '../../src/cli/detect-changes-format.js'; +import { setCliLanguage } from '../../src/cli/i18n/index.js'; + +describe('formatDetectChangesResult — zero-symbol honesty (#3131)', () => { + beforeEach(() => { + setCliLanguage('en'); + }); + + afterEach(() => { + setCliLanguage(null); + }); + + it('prints backend parse-fail message instead of a generic all-clear', () => { + const text = formatDetectChangesResult({ + partial: true, + summary: { + changed_count: 0, + affected_count: 0, + risk_level: 'unknown', + message: 'Could not parse the git diff output — no file headers recognised.', + }, + }); + expect(text).toContain('PARTIAL RESULT'); + expect(text).toContain('Could not parse the git diff output'); + expect(text).not.toContain('No changes detected.'); + }); + + it('does not call a parsed diff with no symbol overlap a clean tree', () => { + const text = formatDetectChangesResult({ + summary: { + changed_count: 0, + affected_count: 0, + changed_files: 1, + risk_level: 'low', + }, + }); + expect(text).toMatch(/Diff touched 1 file/); + expect(text).not.toContain('No changes detected.'); + expect(text).not.toContain('PARTIAL RESULT'); + }); + + it('does not claim no-overlap when a degraded query left changed_count at zero', () => { + const text = formatDetectChangesResult({ + partial: true, + summary: { + changed_count: 0, + affected_count: 0, + changed_files: 1, + risk_level: 'unknown', + }, + }); + expect(text).toContain('PARTIAL RESULT'); + expect(text).not.toMatch(/no indexed symbols overlap/i); + expect(text).not.toContain('No changes detected.'); + }); + + it('keeps the clean-tree sentence only when git produced no files', () => { + const text = formatDetectChangesResult({ + summary: { changed_count: 0, affected_count: 0, changed_files: 0, risk_level: 'none' }, + }); + expect(text).toBe('No changes detected.'); + }); + + it('localizes the production clean-tree payload that carries English summary.message', () => { + setCliLanguage('zh-CN'); + const text = formatDetectChangesResult({ + summary: { + changed_count: 0, + affected_count: 0, + risk_level: 'none', + message: 'No changes detected.', + }, + }); + expect(text).toBe('未检测到变更。'); + }); + + it('localizes confirmed no-overlap under GITNEXUS_LANG=zh-CN', () => { + setCliLanguage('zh-CN'); + const text = formatDetectChangesResult({ + summary: { + changed_count: 0, + affected_count: 0, + changed_files: 1, + risk_level: 'low', + }, + }); + expect(text).toContain('diff 触及 1 个文件'); + expect(text).not.toContain('未检测到变更。'); + expect(text).not.toContain('No changes detected.'); + }); +}); diff --git a/gitnexus/test/unit/eval-formatters.test.ts b/gitnexus/test/unit/eval-formatters.test.ts index cdd12b4fd..57cff0b81 100644 --- a/gitnexus/test/unit/eval-formatters.test.ts +++ b/gitnexus/test/unit/eval-formatters.test.ts @@ -604,7 +604,8 @@ describe('formatDetectChangesResult', () => { // counts at zero. Without the note the pre-commit gate reads as "clean". const result = formatDetectChangesResult({ partial: true, summary: { changed_count: 0 } }); expect(result).toContain('PARTIAL RESULT'); - expect(result).toContain('No changes detected.'); + expect(result).toContain('a graph query failed'); + expect(result).not.toContain('No changes detected.'); }); it('flags a degraded run that still found symbols', () => {