diff --git a/github-run-opencode/run-github-opencode.py b/github-run-opencode/run-github-opencode.py index cc589de..21a0ff1 100755 --- a/github-run-opencode/run-github-opencode.py +++ b/github-run-opencode/run-github-opencode.py @@ -455,17 +455,21 @@ def _main() -> int: # language instructions since there is nothing to respond to. language = get_env("GITHUB_RUN_OPENCODE_LANGUAGE", "zh").strip().lower() existing_prompt = get_env("PROMPT", "") - zh_instruction = ( - "\n\n请使用中文回复。所有分析和说明均使用中文。" - "对于 prompt 中列出的判定关键词,使用其中文版本。" - "\n请勿使用 #N 格式(如 #1、#2)编号,GitHub 会自动将其转换为 issue/PR 引用。" + # Hash-number avoidance — keep in sync with multi-review/src/reviewers.ts. + hash_avoid_zh = ( + "\n请勿使用 #N 格式(如 #1、#2)编号," + "GitHub 会自动将其转换为 issue/PR 引用。" "请使用 1. 2. 3. 或 - 的列表格式。" ) - en_hash_instruction = ( - " Never use #N format (e.g. #1, #2) to number items — " + hash_avoid_en = ( + "\nNever use #N format (e.g. #1, #2) to number items — " "GitHub auto-converts #N to issue/PR references. " "Use 1. 2. 3. or - list format instead." ) + zh_instruction = ( + "\n\n请使用中文回复。所有分析和说明均使用中文。" + "对于 prompt 中列出的判定关键词,使用其中文版本。" + ) + hash_avoid_zh if existing_prompt: if language == "en": set_env("PROMPT", ( @@ -473,7 +477,7 @@ def _main() -> int: + "\n\nIMPORTANT: Respond entirely in English. " "Use English for all analysis, explanations, and output. " "For any verdict keywords listed in the prompt, use their English equivalents." - + en_hash_instruction + + hash_avoid_en )) elif language == "zh": set_env("PROMPT", existing_prompt + zh_instruction) diff --git a/multi-review/package-lock.json b/multi-review/package-lock.json index c8b83ed..cf82151 100644 --- a/multi-review/package-lock.json +++ b/multi-review/package-lock.json @@ -15,6 +15,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "tsup": "^8.0.0", + "tsx": "^4.0.0", "typescript": "^5.7.0" } }, @@ -1547,6 +1548,509 @@ } } }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", diff --git a/multi-review/package.json b/multi-review/package.json index 8304054..7d744d6 100644 --- a/multi-review/package.json +++ b/multi-review/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "build": "tsup", - "check": "tsc --noEmit" + "check": "tsc --noEmit", + "test": "node --import tsx --test src/platform.test.ts" }, "dependencies": { "@opencode-ai/sdk": "^1.0.0", @@ -15,6 +16,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "tsup": "^8.0.0", + "tsx": "^4.0.0", "typescript": "^5.7.0" } } diff --git a/multi-review/src/platform.test.ts b/multi-review/src/platform.test.ts new file mode 100644 index 0000000..2ae8370 --- /dev/null +++ b/multi-review/src/platform.test.ts @@ -0,0 +1,82 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { escapeHashReferences } from "./platform.js"; + +describe("escapeHashReferences", () => { + it("escapes #N after space", () => { + assert.equal(escapeHashReferences("see #2 for details"), "see #\u200B2 for details"); + }); + + it("escapes #N at line start", () => { + assert.equal(escapeHashReferences("#1 issue"), "#\u200B1 issue"); + }); + + it("escapes #N after opening punctuation", () => { + assert.equal(escapeHashReferences("(#1) fix"), "(#\u200B1) fix"); + assert.equal(escapeHashReferences("[#3] related"), "[#\u200B3] related"); + assert.equal(escapeHashReferences(">#5 quote"), ">#\u200B5 quote"); + }); + + it("escapes #N after colon", () => { + assert.equal(escapeHashReferences("issue:#1 here"), "issue:#\u200B1 here"); + }); + + it("escapes #N after Chinese punctuation", () => { + assert.equal(escapeHashReferences("阻塞项:#1 修复"), "阻塞项:#\u200B1 修复"); + assert.equal(escapeHashReferences(",#2 another"), ",#\u200B2 another"); + assert.equal(escapeHashReferences("、#1 fix"), "、#\u200B1 fix"); + }); + + it("escapes #N followed by punctuation", () => { + const result = escapeHashReferences("see #1, then #2"); + assert.equal(result, "see #\u200B1, then #\u200B2"); + }); + + it("does not escape inside fenced code blocks", () => { + const text = "review\n```python\nprint(#1)\n```\nsee #2"; + const result = escapeHashReferences(text); + assert.ok(result.includes("print(#1)")); + assert.ok(result.includes("#\u200B2")); + }); + + it("does not escape inside inline code", () => { + const text = "use `#1` to refer, see #2"; + const result = escapeHashReferences(text); + assert.ok(result.includes("`#1`")); + assert.ok(result.includes("#\u200B2")); + }); + + it("handles multiple fenced code blocks", () => { + const text = "see #1\n```\n#2\n```\nthen #3\n```\n#4\n```\nand #5"; + const result = escapeHashReferences(text); + assert.ok(result.includes("#\u200B1")); + assert.ok(result.includes("#\u200B3")); + assert.ok(result.includes("#\u200B5")); + assert.ok(!result.includes("#\u200B2")); + assert.ok(!result.includes("#\u200B4")); + }); + + it("does not escape markdown headings", () => { + assert.equal(escapeHashReferences("## Heading"), "## Heading"); + assert.equal(escapeHashReferences("# heading\ntext"), "# heading\ntext"); + }); + + it("does not escape ## followed by space", () => { + assert.equal(escapeHashReferences("## 42 items"), "## 42 items"); + }); + + it("returns empty string unchanged", () => { + assert.equal(escapeHashReferences(""), ""); + }); + + it("returns text without matches unchanged", () => { + assert.equal(escapeHashReferences("no references here"), "no references here"); + }); + + it("escapes multiple #N on same line", () => { + assert.equal( + escapeHashReferences("fix #1 and #2 and #3"), + "fix #\u200B1 and #\u200B2 and #\u200B3", + ); + }); +}); diff --git a/multi-review/src/platform.ts b/multi-review/src/platform.ts index d700948..207b298 100644 --- a/multi-review/src/platform.ts +++ b/multi-review/src/platform.ts @@ -154,25 +154,60 @@ function fetchAllGiteaComments(baseUrl: string, token: string): Array<{ id: numb // in agent output before posting to GitHub/Gitea. Prompt-layer instructions // in reviewers.ts / run-github-opencode.py are merely hints to reduce the // volume of corrections needed at this layer. +// +// Note: HASH_NUM_RE uses the global (g) flag. Do not share exec/matchAll +// state across calls — always use replace() or reconstruct matchAll(). +/** Matches "#N" preceded by whitespace, opening punctuation, or line start, + * and followed by whitespace, closing punctuation, or line end. */ const HASH_NUM_RE = /(?:^|(?<=[\s(\[{>:,、:]))(#)(\d{1,6})(?=[\s)\]},:.!?;,。!?、:]|$)/gm; -const CODE_BLOCK_RE = /```[\s\S]*?```/g; - -function escapeHashReferences(text: string): string { +/** Matches triple-backtick fenced code blocks. */ +const FENCED_CODE_RE = /```[\s\S]*?```/g; + +/** Matches inline code (single backtick). */ +const INLINE_CODE_RE = /`[^`]+`/g; + +/** + * Escape hash-number patterns ("#N") in text to prevent GitHub/Gitea from + * auto-converting them to issue/PR references. Inserts a zero-width space + * between "#" and the digit. + * + * Coverage: + * - Escapes "#N" after whitespace, `(`, `[`, `{`, `>`, `:`, and Chinese + * punctuation `:`, `,`, `、`. + * - Skips content inside fenced code blocks (```...```) and inline code + * (`...`). Does NOT handle unclosed fences/backticks. + */ +export function escapeHashReferences(text: string): string { const segments: string[] = []; let lastEnd = 0; - for (const m of text.matchAll(CODE_BLOCK_RE)) { + for (const m of text.matchAll(FENCED_CODE_RE)) { if (m.index !== undefined) { - segments.push(text.slice(lastEnd, m.index).replace(HASH_NUM_RE, "$1\u200B$2")); + segments.push(escapeSegment(text.slice(lastEnd, m.index))); segments.push(m[0]); lastEnd = m.index + m[0].length; } } - segments.push(text.slice(lastEnd).replace(HASH_NUM_RE, "$1\u200B$2")); + const tail = text.slice(lastEnd); + segments.push(escapeSegment(tail)); return segments.join(""); } +function escapeSegment(text: string): string { + const parts: string[] = []; + let lastEnd = 0; + for (const m of text.matchAll(INLINE_CODE_RE)) { + if (m.index !== undefined) { + parts.push(text.slice(lastEnd, m.index).replace(HASH_NUM_RE, "$1\u200B$2")); + parts.push(m[0]); + lastEnd = m.index + m[0].length; + } + } + parts.push(text.slice(lastEnd).replace(HASH_NUM_RE, "$1\u200B$2")); + return parts.join(""); +} + // ── Post PR comment (with PR-context guard) ─────────────────────────── export function postPRComment(body: string): void { diff --git a/tests/test_all.py b/tests/test_all.py index 63ee7d9..eafc260 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -927,22 +927,35 @@ def _run_escape(self, text: str) -> str: r"(?:^|(?<=[\s(\[{>:,、:]))(#)(\d{1,6})(?=[\s)\]},:.!?;,。!?、:]|$)", re_mod.MULTILINE, ) - CODE_BLOCK_RE = re_mod.compile(r"```[\s\S]*?```", re_mod.MULTILINE) + FENCED_CODE_RE = re_mod.compile(r"```[\s\S]*?```", re_mod.MULTILINE) + INLINE_CODE_RE = re_mod.compile(r"`[^`]+`") ZWSP = "\u200B" - def escape_proper(t: str) -> str: + def escape_text(t: str) -> str: segments = [] last_end = 0 - for m in CODE_BLOCK_RE.finditer(t): + for m in FENCED_CODE_RE.finditer(t): pre = t[last_end:m.start()] - segments.append(HASH_NUM_RE.sub(lambda _: _.group(1) + ZWSP + _.group(2), pre)) + segments.append(_escape_segment(pre)) segments.append(m.group()) last_end = m.end() remaining = t[last_end:] - segments.append(HASH_NUM_RE.sub(lambda _: _.group(1) + ZWSP + _.group(2), remaining)) + segments.append(_escape_segment(remaining)) return "".join(segments) - return escape_proper(text) + def _escape_segment(t: str) -> str: + parts = [] + last_end = 0 + for m in INLINE_CODE_RE.finditer(t): + pre = t[last_end:m.start()] + parts.append(HASH_NUM_RE.sub(lambda _: _.group(1) + ZWSP + _.group(2), pre)) + parts.append(m.group()) + last_end = m.end() + remaining = t[last_end:] + parts.append(HASH_NUM_RE.sub(lambda _: _.group(1) + ZWSP + _.group(2), remaining)) + return "".join(parts) + + return escape_text(text) def test_line_start(self): self.assertIn("#\u200B1", self._run_escape("#1 issue")) @@ -971,12 +984,18 @@ def test_after_chinese_comma(self): def test_after_chinese顿号(self): self.assertIn("#\u200B1", self._run_escape("、#1 fix")) - def test_code_block_not_escaped(self): + def test_fenced_code_block_not_escaped(self): text = "review\n```python\nprint(#1)\n```\nsee #2" result = self._run_escape(text) self.assertIn("print(#1)", result) self.assertIn("#\u200B2", result) + def test_inline_code_not_escaped(self): + text = "use `#1` to refer, see #2" + result = self._run_escape(text) + self.assertIn("`#1`", result) + self.assertIn("#\u200B2", result) + def test_no_match_in_markdown_heading(self): text = "## Heading" result = self._run_escape(text) @@ -991,7 +1010,7 @@ def test_trailing_number_with_punctuation(self): self.assertIn("#\u200B1,", self._run_escape("see #1, then #2")) self.assertIn("#\u200B2", self._run_escape("see #1, then #2")) - def test_multiple_code_blocks(self): + def test_multiple_fenced_code_blocks(self): text = "see #1\n```\n#2\n```\nthen #3\n```\n#4\n```\nand #5" result = self._run_escape(text) self.assertIn("#\u200B1", result) @@ -1001,5 +1020,62 @@ def test_multiple_code_blocks(self): self.assertNotIn("#\u200B4", result) +class TestCrossLanguageHashInstructionConsistency(unittest.TestCase): + """Verify that hash-avoidance instructions in TS and Python stay in sync.""" + + def _extract_ts_hash_avoid(self): + ts_file = REPO_ROOT / "multi-review" / "src" / "reviewers.ts" + ts_content = ts_file.read_text() + + ts_zh_match = re.search( + r'HASH_AVOID_ZH\s*=\s*"((?:[^"\\]|\\.)*)"\s*\+\s*"((?:[^"\\]|\\.)*)"', + ts_content, + ) + ts_en_match = re.search( + r'HASH_AVOID_EN\s*=\s*"((?:[^"\\]|\\.)*)"\s*\+\s*"((?:[^"\\]|\\.)*)"\s*\+\s*"((?:[^"\\]|\\.)*)"', + ts_content, + ) + self.assertIsNotNone(ts_zh_match, "HASH_AVOID_ZH not found in reviewers.ts") + self.assertIsNotNone(ts_en_match, "HASH_AVOID_EN not found in reviewers.ts") + ts_zh = (ts_zh_match.group(1) + ts_zh_match.group(2)).replace("\\n", "\n") + ts_en = (ts_en_match.group(1) + ts_en_match.group(2) + ts_en_match.group(3)).replace("\\n", "\n") + return ts_zh, ts_en + + def _extract_py_hash_avoid(self): + py_file = REPO_ROOT / "github-run-opencode" / "run-github-opencode.py" + py_content = py_file.read_text() + + py_zh_match = re.search( + r'hash_avoid_zh\s*=\s*\((.*?)\)\s*\n', + py_content, + re.DOTALL, + ) + py_en_match = re.search( + r'hash_avoid_en\s*=\s*\((.*?)\)\s*\n', + py_content, + re.DOTALL, + ) + self.assertIsNotNone(py_zh_match, "hash_avoid_zh not found in run-github-opencode.py") + self.assertIsNotNone(py_en_match, "hash_avoid_en not found in run-github-opencode.py") + + def extract_concat_strings(block: str) -> str: + parts = re.findall(r'"((?:[^"\\]|\\.)*)"', block) + return "".join(parts).replace("\\n", "\n") + + py_zh = extract_concat_strings(py_zh_match.group(1)) + py_en = extract_concat_strings(py_en_match.group(1)) + return py_zh, py_en + + def test_zh_instruction_matches(self): + ts_zh, _ = self._extract_ts_hash_avoid() + py_zh, _ = self._extract_py_hash_avoid() + self.assertEqual(ts_zh, py_zh, "ZH hash-avoidance instruction differs between TS and Python") + + def test_en_instruction_matches(self): + _, ts_en = self._extract_ts_hash_avoid() + _, py_en = self._extract_py_hash_avoid() + self.assertEqual(ts_en, py_en, "EN hash-avoidance instruction differs between TS and Python") + + if __name__ == "__main__": unittest.main(verbosity=2)