|
| 1 | +"""2026-09-23 opencli/codex 双 SDK 发布会话实测痛点的修复回归。 |
| 2 | +
|
| 3 | +锚定四个实测坑: |
| 4 | +- POM 4.1.0 + mvnw 仓库:gate_lib 的 mvnw 感知读取的 `executable` 键 |
| 5 | + 生产者(java_project)从不产出 → 感知死代码,门禁退回裸 mvn 必失败; |
| 6 | +- 门禁目标仓库不显式:cwd 漂移后 AI 以为改的是 A 仓,实际门禁扫的是 B 仓; |
| 7 | +- ruff [*] 可自动修复项藏在长输出尾部,AI 修完才发现有捷径; |
| 8 | +- 仓库级 skipGate 对该克隆所有分支生效且跨会话残留,提示里没有作用域警告。 |
| 9 | +""" |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +import os |
| 13 | +import stat |
| 14 | +import sys |
| 15 | +import tempfile |
| 16 | +import unittest |
| 17 | +from pathlib import Path |
| 18 | + |
| 19 | +PLUGIN = Path(__file__).resolve().parents[1] |
| 20 | +sys.path.insert(0, str(PLUGIN / "hooks")) |
| 21 | +sys.path.insert(0, str(PLUGIN / "scripts")) |
| 22 | + |
| 23 | +import gate_lib |
| 24 | +import scope |
| 25 | +from java_project import analyze |
| 26 | + |
| 27 | + |
| 28 | +class JavaExecutableContractTest(unittest.TestCase): |
| 29 | + """生产者-消费者契约:java_project 必须产出顶层 executable 键。 |
| 30 | +
|
| 31 | + 68c8a37 曾让 mvnw 感知成为死代码——消费者读的键生产者从不写, |
| 32 | + 两侧各自的测试都绿,合在一起门禁照旧退回裸 mvn(实测)。 |
| 33 | + """ |
| 34 | + |
| 35 | + def setUp(self): |
| 36 | + self.tmp = tempfile.TemporaryDirectory() |
| 37 | + self.addCleanup(self.tmp.cleanup) |
| 38 | + self.root = Path(self.tmp.name) |
| 39 | + (self.root / "pom.xml").write_text( |
| 40 | + '<project xmlns="http://maven.apache.org/POM/4.1.0">' |
| 41 | + "<modelVersion>4.1.0</modelVersion></project>", encoding="utf-8") |
| 42 | + |
| 43 | + def test_executable_wrapper_present_and_executable(self): |
| 44 | + mvnw = self.root / "mvnw" |
| 45 | + mvnw.write_text("#!/bin/sh\nexit 0\n") |
| 46 | + mvnw.chmod(0o755) |
| 47 | + plan = analyze(str(self.root)) |
| 48 | + self.assertEqual(plan["status"], "PLANNED") |
| 49 | + self.assertEqual(plan.get("executable"), "./mvnw", |
| 50 | + "生产者必须产出顶层 executable 键(门禁消费者直接读取)") |
| 51 | + |
| 52 | + def test_executable_wrapper_missing_falls_back_to_mvn(self): |
| 53 | + plan = analyze(str(self.root)) |
| 54 | + self.assertEqual(plan.get("executable"), "mvn") |
| 55 | + |
| 56 | + def test_executable_wrapper_not_executable_is_unverified(self): |
| 57 | + mvnw = self.root / "mvnw" |
| 58 | + mvnw.write_text("#!/bin/sh\nexit 0\n") |
| 59 | + mvnw.chmod(0o644) |
| 60 | + plan = analyze(str(self.root)) |
| 61 | + self.assertEqual(plan["status"], "UNVERIFIED") |
| 62 | + self.assertTrue(any("不可执行" in r for r in plan["reasons"])) |
| 63 | + |
| 64 | + |
| 65 | +class JavaExecutableFromPlanTest(unittest.TestCase): |
| 66 | + """消费者纯函数:两条来源优先级 + 非包装器不参与。""" |
| 67 | + |
| 68 | + def test_prefers_top_level_key(self): |
| 69 | + plan = {"executable": "./mvnw", |
| 70 | + "commands": [{"argv": ["mvn", "verify"]}]} |
| 71 | + self.assertEqual(gate_lib._java_executable_from_plan(plan), "./mvnw") |
| 72 | + |
| 73 | + def test_falls_back_to_wrapper_in_command_argv(self): |
| 74 | + plan = {"commands": [{"argv": ["./mvnw", "-B", "verify"]}]} |
| 75 | + self.assertEqual(gate_lib._java_executable_from_plan(plan), "./mvnw") |
| 76 | + |
| 77 | + def test_ignores_non_wrapper_command_heads(self): |
| 78 | + plan = {"commands": [{"argv": ["mvn", "verify"]}]} |
| 79 | + self.assertIsNone(gate_lib._java_executable_from_plan(plan)) |
| 80 | + |
| 81 | + def test_empty_plan_returns_none(self): |
| 82 | + self.assertIsNone(gate_lib._java_executable_from_plan({})) |
| 83 | + |
| 84 | + |
| 85 | +class DirectiveRepoLabelTest(unittest.TestCase): |
| 86 | + """目标仓库首行:cwd 漂移扫到非目标仓时,AI 第一眼就能发现。""" |
| 87 | + |
| 88 | + def test_directive_names_target_repo_when_root_given(self): |
| 89 | + report = gate_lib.gate_directive( |
| 90 | + [("shell", "EXE001 detail", "fix cmd", "hint")], |
| 91 | + project_root="/tmp/some-repo") |
| 92 | + self.assertIn("本次门禁目标仓库", report) |
| 93 | + self.assertIn("/tmp/some-repo", report) |
| 94 | + |
| 95 | + def test_directive_without_root_omits_label(self): |
| 96 | + report = gate_lib.gate_directive( |
| 97 | + [("shell", "detail", "fix", "hint")]) |
| 98 | + self.assertNotIn("本次门禁目标仓库", report) |
| 99 | + |
| 100 | + def test_directive_warns_clone_wide_scope_of_repo_level_skip(self): |
| 101 | + report = gate_lib.gate_directive( |
| 102 | + [("shell", "detail", "fix", "hint")], |
| 103 | + project_root="/tmp/some-repo") |
| 104 | + self.assertIn("所有分支", report) |
| 105 | + |
| 106 | + |
| 107 | +class PythonFixableHintTest(unittest.TestCase): |
| 108 | + """ruff [*] 可自动修复项:提示必须显式给出 ruff check --fix。""" |
| 109 | + |
| 110 | + def test_fixable_hint_appended_for_python(self): |
| 111 | + blocks = gate_lib._failure_detail_blocks( |
| 112 | + [("python", "UP009 [*] UTF-8 encoding declaration is unnecessary", |
| 113 | + "remove comment", "hint")]) |
| 114 | + joined = "\n".join(blocks) |
| 115 | + self.assertIn("ruff check --fix", joined) |
| 116 | + |
| 117 | + def test_no_fixable_hint_for_shell(self): |
| 118 | + blocks = gate_lib._failure_detail_blocks( |
| 119 | + [("shell", "SC2086 detail", "quote it", "hint")]) |
| 120 | + self.assertNotIn("ruff check --fix", "\n".join(blocks)) |
| 121 | + |
| 122 | + |
| 123 | +class RuleSummaryTest(unittest.TestCase): |
| 124 | + """规则聚合计数:whack-a-mole 的治理——AI 一次看到问题全集。""" |
| 125 | + |
| 126 | + def test_aggregates_rule_codes(self): |
| 127 | + full = ("scripts/x.py:1:1 EXE001 Shebang\n" |
| 128 | + "scripts/y.py:2:1 EXE001 Shebang\n" |
| 129 | + "scripts/z.py:28:27 DTZ011 date.today() used") |
| 130 | + self.assertEqual( |
| 131 | + gate_lib._rule_summary(full), |
| 132 | + "规则汇总: DTZ011×1 EXE001×2") |
| 133 | + |
| 134 | + def test_empty_for_non_linter_output(self): |
| 135 | + self.assertEqual(gate_lib._rule_summary("plain text\nno codes"), "") |
| 136 | + |
| 137 | + |
| 138 | +class ScopeChildGitRepoTest(unittest.TestCase): |
| 139 | + """非 git 根(会话工作区)的全量扫描:子 git 仓必须排除。""" |
| 140 | + |
| 141 | + def setUp(self): |
| 142 | + self.tmp = tempfile.TemporaryDirectory() |
| 143 | + self.addCleanup(self.tmp.cleanup) |
| 144 | + self.root = Path(self.tmp.name) |
| 145 | + (self.root / "sub-repo").mkdir() |
| 146 | + (self.root / "sub-repo" / ".git").mkdir() |
| 147 | + (self.root / "loose.sh").write_text("#!/bin/sh\n", encoding="utf-8") |
| 148 | + |
| 149 | + def test_child_git_repos_detected(self): |
| 150 | + self.assertFalse(scope.is_git_repo(self.root)) |
| 151 | + self.assertEqual(scope.child_git_repo_names(self.root), ["sub-repo"]) |
| 152 | + |
| 153 | + def test_find_gate_excludes_child_repos(self): |
| 154 | + cmd = ["bash", "-c", |
| 155 | + "find . \\( -name '*.sh' \\) -type f " |
| 156 | + "-not -path '*/node_modules/*' -print0 | " |
| 157 | + "xargs -0 -r shellcheck --severity=warning"] |
| 158 | + out = scope.scope_cmd(cmd, str(self.root), full_excludes=True) |
| 159 | + joined = " ".join(out) |
| 160 | + self.assertIn("-not -path '*/sub-repo/*'", joined) |
| 161 | + self.assertIn("-not -path '*/target/*'", joined) |
| 162 | + |
| 163 | + def test_git_repo_root_gets_no_child_excludes(self): |
| 164 | + (self.root / ".git").mkdir() |
| 165 | + cmd = ["bash", "-c", |
| 166 | + "find . -name '*.sh' -type f -print0 | xargs -0 -r true"] |
| 167 | + out = scope.scope_cmd(cmd, str(self.root), full_excludes=True) |
| 168 | + self.assertNotIn("-not -path '*/sub-repo/*'", " ".join(out)) |
| 169 | + |
| 170 | + |
| 171 | +if __name__ == "__main__": |
| 172 | + unittest.main() |
0 commit comments