@ranger1/dx 0.1.48 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,21 +8,47 @@ Tests cover:
8
8
  3. Edge cases: empty input, malformed data, cross-reviewer matching
9
9
  """
10
10
 
11
- import pytest
12
- from unittest.mock import patch, MagicMock
13
- import sys
11
+ import importlib.util
12
+ import json
13
+ import subprocess
14
+ from collections.abc import Mapping, Sequence
14
15
  from pathlib import Path
16
+ from types import SimpleNamespace
17
+ from typing import Callable, cast
18
+
19
+ import pytest
20
+
21
+
22
+ def _load_pr_review_aggregate_module():
23
+ module_path = Path(__file__).with_name("pr_review_aggregate.py")
24
+ spec = importlib.util.spec_from_file_location("pr_review_aggregate", module_path)
25
+ if spec is None or spec.loader is None:
26
+ raise RuntimeError(f"Failed to load module spec: {module_path}")
27
+ module = importlib.util.module_from_spec(spec)
28
+ spec.loader.exec_module(module)
29
+ return module
15
30
 
16
- # Add parent directory to path for importing pr_review_aggregate
17
- sys.path.insert(0, str(Path(__file__).parent))
18
31
 
19
- # Import functions under test
20
- from pr_review_aggregate import (
21
- _parse_decision_log,
22
- _filter_by_decision_log,
23
- _parse_escalation_groups_json,
24
- _parse_escalation_groups_b64,
32
+ _pr_review_aggregate = _load_pr_review_aggregate_module()
33
+
34
+ _parse_decision_log = cast(Callable[[str], list[dict[str, object]]], getattr(_pr_review_aggregate, "_parse_decision_log"))
35
+ _filter_by_decision_log = cast(
36
+ Callable[[Sequence[Mapping[str, object]], Sequence[Mapping[str, object]], list[list[str]]], list[dict[str, object]]],
37
+ getattr(_pr_review_aggregate, "_filter_by_decision_log"),
38
+ )
39
+ _parse_escalation_groups_json = cast(
40
+ Callable[[str], list[list[str]]],
41
+ getattr(_pr_review_aggregate, "_parse_escalation_groups_json"),
42
+ )
43
+ _parse_escalation_groups_b64 = cast(
44
+ Callable[[str], list[list[str]]],
45
+ getattr(_pr_review_aggregate, "_parse_escalation_groups_b64"),
46
+ )
47
+ _check_existing_comment = cast(
48
+ Callable[[int, str, int, str], bool],
49
+ getattr(_pr_review_aggregate, "_check_existing_comment"),
25
50
  )
51
+ _MARKER = cast(str, getattr(_pr_review_aggregate, "MARKER"))
26
52
 
27
53
 
28
54
  # ============================================================
@@ -30,13 +56,13 @@ from pr_review_aggregate import (
30
56
  # ============================================================
31
57
 
32
58
  @pytest.fixture
33
- def empty_decision_log():
59
+ def empty_decision_log() -> str:
34
60
  """Empty decision log markdown."""
35
61
  return ""
36
62
 
37
63
 
38
64
  @pytest.fixture
39
- def valid_decision_log():
65
+ def valid_decision_log() -> str:
40
66
  """Valid decision log with Fixed and Rejected entries."""
41
67
  return """# Decision Log
42
68
 
@@ -46,20 +72,24 @@ PR: 123
46
72
 
47
73
  ### Fixed
48
74
  - id: CDX-001
75
+ file: apps/backend/src/api.ts
49
76
  commit: abc123
50
77
  essence: JSON.parse 未捕获异常
51
78
 
52
79
  - id: GMN-002
80
+ file: apps/front/src/ErrorBoundary.tsx
53
81
  commit: def456
54
82
  essence: 缺少错误边界处理
55
83
 
56
84
  ### Rejected
57
85
  - id: GMN-004
86
+ file: apps/front/src/Component.tsx
58
87
  priority: P2
59
88
  reason: 需要产品决策,超出 PR 范围
60
89
  essence: 组件拆分建议
61
90
 
62
91
  - id: CLD-003
92
+ file: apps/backend/src/db.ts
63
93
  priority: P3
64
94
  reason: 性能优化非当前优先级
65
95
  essence: 批量查询优化
@@ -67,7 +97,38 @@ PR: 123
67
97
 
68
98
 
69
99
  @pytest.fixture
70
- def malformed_decision_log():
100
+ def valid_decision_log_legacy_no_file() -> str:
101
+ """Legacy decision log fixture without the file: field (backward compat)."""
102
+ return """# Decision Log
103
+
104
+ PR: 123
105
+
106
+ ## Round 1
107
+
108
+ ### Fixed
109
+ - id: CDX-001
110
+ commit: abc123
111
+ essence: JSON.parse 未捕获异常
112
+
113
+ - id: GMN-002
114
+ commit: def456
115
+ essence: 缺少错误边界处理
116
+
117
+ ### Rejected
118
+ - id: GMN-004
119
+ priority: P2
120
+ reason: 需要产品决策,超出 PR 范围
121
+ essence: 组件拆分建议
122
+
123
+ - id: CLD-003
124
+ priority: P3
125
+ reason: 性能优化非当前优先级
126
+ essence: 批量查询优化
127
+ """
128
+
129
+
130
+ @pytest.fixture
131
+ def malformed_decision_log() -> str:
71
132
  """Malformed decision log with missing fields and bad formatting."""
72
133
  return """# Decision Log
73
134
 
@@ -90,7 +151,7 @@ Some random text that should be ignored
90
151
 
91
152
 
92
153
  @pytest.fixture
93
- def sample_findings():
154
+ def sample_findings() -> list[dict[str, object]]:
94
155
  """Sample findings list for filter tests."""
95
156
  return [
96
157
  {
@@ -137,7 +198,7 @@ def sample_findings():
137
198
 
138
199
 
139
200
  @pytest.fixture
140
- def prior_decisions():
201
+ def prior_decisions() -> list[dict[str, object]]:
141
202
  """Sample prior decisions from _parse_decision_log."""
142
203
  return [
143
204
  {
@@ -160,7 +221,7 @@ def prior_decisions():
160
221
  # Test: _parse_decision_log() - Empty Input
161
222
  # ============================================================
162
223
 
163
- def test_parse_decision_log_empty(empty_decision_log):
224
+ def test_parse_decision_log_empty(empty_decision_log: str) -> None:
164
225
  """
165
226
  Test that empty decision log returns empty list.
166
227
 
@@ -177,7 +238,7 @@ def test_parse_decision_log_empty(empty_decision_log):
177
238
  # Test: _parse_decision_log() - Valid Input
178
239
  # ============================================================
179
240
 
180
- def test_parse_decision_log_valid(valid_decision_log):
241
+ def test_parse_decision_log_valid(valid_decision_log: str) -> None:
181
242
  """
182
243
  Test that valid decision log is parsed into structured data.
183
244
 
@@ -194,6 +255,7 @@ def test_parse_decision_log_valid(valid_decision_log):
194
255
  fixed_1 = result[0]
195
256
  assert fixed_1["id"] == "CDX-001"
196
257
  assert fixed_1["status"] == "fixed"
258
+ assert fixed_1["file"] == "apps/backend/src/api.ts"
197
259
  assert fixed_1["commit"] == "abc123"
198
260
  assert fixed_1["essence"] == "JSON.parse 未捕获异常"
199
261
 
@@ -201,6 +263,7 @@ def test_parse_decision_log_valid(valid_decision_log):
201
263
  fixed_2 = result[1]
202
264
  assert fixed_2["id"] == "GMN-002"
203
265
  assert fixed_2["status"] == "fixed"
266
+ assert fixed_2["file"] == "apps/front/src/ErrorBoundary.tsx"
204
267
  assert fixed_2["commit"] == "def456"
205
268
  assert fixed_2["essence"] == "缺少错误边界处理"
206
269
 
@@ -208,6 +271,7 @@ def test_parse_decision_log_valid(valid_decision_log):
208
271
  rejected_1 = result[2]
209
272
  assert rejected_1["id"] == "GMN-004"
210
273
  assert rejected_1["status"] == "rejected"
274
+ assert rejected_1["file"] == "apps/front/src/Component.tsx"
211
275
  assert rejected_1["priority"] == "P2"
212
276
  assert rejected_1["reason"] == "需要产品决策,超出 PR 范围"
213
277
  assert rejected_1["essence"] == "组件拆分建议"
@@ -216,14 +280,31 @@ def test_parse_decision_log_valid(valid_decision_log):
216
280
  rejected_2 = result[3]
217
281
  assert rejected_2["id"] == "CLD-003"
218
282
  assert rejected_2["status"] == "rejected"
283
+ assert rejected_2["file"] == "apps/backend/src/db.ts"
219
284
  assert rejected_2["priority"] == "P3"
220
285
 
221
286
 
287
+ def test_parse_decision_log_legacy_without_file(valid_decision_log_legacy_no_file: str) -> None:
288
+ """Decision log entries without file: should still parse (backward compat)."""
289
+ result = _parse_decision_log(valid_decision_log_legacy_no_file)
290
+
291
+ # Should have 4 entries (2 Fixed, 2 Rejected)
292
+ assert len(result) == 4
293
+
294
+ # Basic shape should still be present
295
+ for entry in result:
296
+ assert "id" in entry
297
+ assert "status" in entry
298
+
299
+ # And file should be optional
300
+ assert all(("file" not in e) or (e["file"] in (None, "")) for e in result)
301
+
302
+
222
303
  # ============================================================
223
304
  # Test: _parse_decision_log() - Malformed Input
224
305
  # ============================================================
225
306
 
226
- def test_parse_decision_log_malformed(malformed_decision_log):
307
+ def test_parse_decision_log_malformed(malformed_decision_log: str) -> None:
227
308
  """
228
309
  Test that malformed decision log degrades gracefully.
229
310
 
@@ -247,7 +328,7 @@ def test_parse_decision_log_malformed(malformed_decision_log):
247
328
  # Test: _filter_by_decision_log() - Fixed Issues
248
329
  # ============================================================
249
330
 
250
- def test_filter_fixed_issues(sample_findings, prior_decisions):
331
+ def test_filter_fixed_issues(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
251
332
  """
252
333
  Test that findings matching Fixed decisions are filtered out.
253
334
 
@@ -255,7 +336,7 @@ def test_filter_fixed_issues(sample_findings, prior_decisions):
255
336
  When: _filter_by_decision_log() is called with empty escalation_groups
256
337
  Then: CDX-001 is filtered out
257
338
  """
258
- escalation_groups = []
339
+ escalation_groups: list[list[str]] = []
259
340
 
260
341
  result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
261
342
 
@@ -271,7 +352,7 @@ def test_filter_fixed_issues(sample_findings, prior_decisions):
271
352
  # Test: _filter_by_decision_log() - Rejected Without Escalation
272
353
  # ============================================================
273
354
 
274
- def test_filter_rejected_without_escalation(sample_findings, prior_decisions):
355
+ def test_filter_rejected_without_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
275
356
  """
276
357
  Test that findings matching Rejected decisions are filtered out when NOT in escalation_groups.
277
358
 
@@ -280,7 +361,7 @@ def test_filter_rejected_without_escalation(sample_findings, prior_decisions):
280
361
  When: _filter_by_decision_log() is called
281
362
  Then: GMN-004 is filtered out
282
363
  """
283
- escalation_groups = []
364
+ escalation_groups: list[list[str]] = []
284
365
 
285
366
  result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
286
367
 
@@ -296,7 +377,7 @@ def test_filter_rejected_without_escalation(sample_findings, prior_decisions):
296
377
  # Test: _filter_by_decision_log() - Rejected With Escalation
297
378
  # ============================================================
298
379
 
299
- def test_filter_rejected_with_escalation(sample_findings, prior_decisions):
380
+ def test_filter_rejected_with_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
300
381
  """
301
382
  Test that findings matching Rejected decisions are kept when in escalation_groups.
302
383
 
@@ -322,7 +403,7 @@ def test_filter_rejected_with_escalation(sample_findings, prior_decisions):
322
403
  # Test: _filter_by_decision_log() - Cross-Reviewer Match
323
404
  # ============================================================
324
405
 
325
- def test_filter_cross_reviewer_match():
406
+ def test_filter_cross_reviewer_match() -> None:
326
407
  """
327
408
  Test that findings with different reviewer IDs but same essence are filtered.
328
409
 
@@ -381,7 +462,7 @@ def test_filter_cross_reviewer_match():
381
462
  # Test: _parse_escalation_groups_json()
382
463
  # ============================================================
383
464
 
384
- def test_parse_escalation_groups_json_valid():
465
+ def test_parse_escalation_groups_json_valid() -> None:
385
466
  """Test parsing valid escalation groups JSON."""
386
467
  json_str = '{"escalationGroups": [["GMN-004", "CLD-007"], ["CDX-001", "GMN-005"]]}'
387
468
  result = _parse_escalation_groups_json(json_str)
@@ -391,13 +472,13 @@ def test_parse_escalation_groups_json_valid():
391
472
  assert ["CDX-001", "GMN-005"] in result
392
473
 
393
474
 
394
- def test_parse_escalation_groups_json_empty():
475
+ def test_parse_escalation_groups_json_empty() -> None:
395
476
  """Test parsing empty escalation groups JSON."""
396
477
  result = _parse_escalation_groups_json("")
397
478
  assert result == []
398
479
 
399
480
 
400
- def test_parse_escalation_groups_json_malformed():
481
+ def test_parse_escalation_groups_json_malformed() -> None:
401
482
  """Test parsing malformed JSON returns empty list."""
402
483
  result = _parse_escalation_groups_json("not valid json {{{")
403
484
  assert result == []
@@ -407,7 +488,7 @@ def test_parse_escalation_groups_json_malformed():
407
488
  # Test: _parse_escalation_groups_b64()
408
489
  # ============================================================
409
490
 
410
- def test_parse_escalation_groups_b64_valid():
491
+ def test_parse_escalation_groups_b64_valid() -> None:
411
492
  """Test parsing valid base64-encoded escalation groups."""
412
493
  import base64
413
494
  json_str = '{"escalationGroups": [["GMN-004", "CLD-007"]]}'
@@ -419,13 +500,13 @@ def test_parse_escalation_groups_b64_valid():
419
500
  assert ["GMN-004", "CLD-007"] in result
420
501
 
421
502
 
422
- def test_parse_escalation_groups_b64_empty():
503
+ def test_parse_escalation_groups_b64_empty() -> None:
423
504
  """Test parsing empty base64 string."""
424
505
  result = _parse_escalation_groups_b64("")
425
506
  assert result == []
426
507
 
427
508
 
428
- def test_parse_escalation_groups_b64_invalid():
509
+ def test_parse_escalation_groups_b64_invalid() -> None:
429
510
  """Test parsing invalid base64 returns empty list."""
430
511
  result = _parse_escalation_groups_b64("not-valid-base64!!!")
431
512
  assert result == []
@@ -435,7 +516,7 @@ def test_parse_escalation_groups_b64_invalid():
435
516
  # Test: Integration - Full Workflow
436
517
  # ============================================================
437
518
 
438
- def test_integration_full_filter_workflow():
519
+ def test_integration_full_filter_workflow() -> None:
439
520
  """
440
521
  Integration test: parse decision log and filter findings.
441
522
 
@@ -496,5 +577,125 @@ PR: 456
496
577
  assert len(result) == 2
497
578
 
498
579
 
580
+ # ============================================================
581
+ # Test: _check_existing_comment() - PR comment idempotency
582
+ # ============================================================
583
+
584
+
585
+ def _patch_subprocess_run_for_gh_comments(monkeypatch: pytest.MonkeyPatch, comments: list[dict[str, object]], returncode: int = 0) -> None:
586
+ stdout = json.dumps(comments, ensure_ascii=True)
587
+
588
+ def _fake_run(*_args: object, **_kwargs: object) -> SimpleNamespace:
589
+ return SimpleNamespace(returncode=returncode, stdout=stdout)
590
+
591
+ monkeypatch.setattr(subprocess, "run", _fake_run)
592
+
593
+
594
+ @pytest.mark.parametrize(
595
+ "comment_type,round_num,expected_header",
596
+ [
597
+ ("review-summary", 2, "## Review Summary (Round 2)"),
598
+ ("fix-report", 2, "## Fix Report (Round 2)"),
599
+ ("final-report", 2, "## Final Report"),
600
+ ],
601
+ )
602
+ def test_check_existing_comment_true_when_marker_header_and_runid_match(
603
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
604
+ ) -> None:
605
+ pr_number = 123
606
+ run_id = "run-abc"
607
+ body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {run_id}"])
608
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
609
+
610
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is True
611
+
612
+
613
+ @pytest.mark.parametrize(
614
+ "comment_type,round_num,expected_header",
615
+ [
616
+ ("review-summary", 3, "## Review Summary (Round 3)"),
617
+ ("fix-report", 3, "## Fix Report (Round 3)"),
618
+ ("final-report", 3, "## Final Report"),
619
+ ],
620
+ )
621
+ def test_check_existing_comment_false_when_marker_missing(
622
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
623
+ ) -> None:
624
+ pr_number = 456
625
+ run_id = "run-xyz"
626
+ body = "\n".join(["", expected_header, "", f"RunId: {run_id}"])
627
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
628
+
629
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
630
+
631
+
632
+ @pytest.mark.parametrize(
633
+ "comment_type,round_num,expected_header,wrong_header",
634
+ [
635
+ (
636
+ "review-summary",
637
+ 2,
638
+ "## Review Summary (Round 2)",
639
+ "## Fix Report (Round 2)",
640
+ ),
641
+ (
642
+ "fix-report",
643
+ 2,
644
+ "## Fix Report (Round 2)",
645
+ "## Review Summary (Round 2)",
646
+ ),
647
+ (
648
+ "final-report",
649
+ 2,
650
+ "## Final Report",
651
+ "## Review Summary (Round 2)",
652
+ ),
653
+ ],
654
+ )
655
+ def test_check_existing_comment_false_when_header_mismatched(
656
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str, wrong_header: str
657
+ ) -> None:
658
+ pr_number = 789
659
+ run_id = "run-123"
660
+
661
+ body = "\n".join([_MARKER, "", wrong_header, "", f"RunId: {run_id}"])
662
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
663
+
664
+ assert expected_header not in body
665
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
666
+
667
+
668
+ @pytest.mark.parametrize(
669
+ "comment_type,round_num,expected_header",
670
+ [
671
+ ("review-summary", 1, "## Review Summary (Round 1)"),
672
+ ("fix-report", 1, "## Fix Report (Round 1)"),
673
+ ("final-report", 1, "## Final Report"),
674
+ ],
675
+ )
676
+ def test_check_existing_comment_false_when_runid_mismatched(
677
+ monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
678
+ ) -> None:
679
+ pr_number = 101
680
+ run_id = "run-a"
681
+ other_run_id = "run-b"
682
+
683
+ body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {other_run_id}"])
684
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
685
+
686
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
687
+
688
+
689
+ def test_check_existing_comment_false_when_subprocess_run_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
690
+ pr_number = 999
691
+ run_id = "run-nonzero"
692
+ round_num = 2
693
+ comment_type = "review-summary"
694
+ body = "\n".join([_MARKER, "", "## Review Summary (Round 2)", "", f"RunId: {run_id}"])
695
+
696
+ _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}], returncode=1)
697
+ assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
698
+
699
+
499
700
  if __name__ == "__main__":
500
- pytest.main([__file__, "-v"])
701
+ _ = pytest.main([__file__, "-v"])
@@ -15,6 +15,19 @@ agent: sisyphus
15
15
  ## 输入
16
16
 
17
17
  - `{{PR_NUMBER}}`
18
+ - `round`(默认 1,由调用者/循环控制)
19
+
20
+ ## 唯一标识 runId(强制)
21
+
22
+ - 全局唯一标识 `runId` 格式:`<PR>-<ROUND>-<HEAD_SHORT>`
23
+ - 其中:
24
+ - `<PR>`:PR 编号
25
+ - `<ROUND>`:当前轮次
26
+ - `<HEAD_SHORT>`:`headOid` 的前 7 位(git rev-parse --short HEAD)
27
+ - 生成者:
28
+ - 第 1 步 `pr-context` 负责计算并返回 `runId`(基于当前 checkout 的 headOid)
29
+ - 后续所有步骤(reviewers, aggregate, fix)必须透传并使用该 `runId`
30
+ - 禁止任何下游步骤自行生成或篡改 `runId`
18
31
 
19
32
  ## Cache 约定(强制)
20
33
 
@@ -26,6 +39,7 @@ agent: sisyphus
26
39
  - `pr-precheck`
27
40
  - `pr-context`
28
41
  - `codex-reviewer`
42
+
29
43
  - `claude-reviewer`
30
44
  - `gemini-reviewer`
31
45
  - `gh-thread-reviewer`
@@ -37,14 +51,15 @@ agent: sisyphus
37
51
 
38
52
  0. Task: `pr-precheck`(强制 gate:编译/预检必须先通过)
39
53
 
40
- - prompt 必须包含:`PR #{{PR_NUMBER}}`
41
- - 若返回 `{"error":"..."}`:立即终止本轮并回传错误(不再调用 reviewers)
54
+ - prompt 必须包含:`PR #{{PR_NUMBER}}`、`round: <ROUND>`(precheck 需计算并返回 runId,格式同 context)
55
+ - 若返回 `{"error":"..."}`:立即终止本轮并回传错误
42
56
  - 若返回 `{"ok":false,"fixFile":"..."}`:
57
+ - 预检阶段 runId 同样基于 `headOid` 生成(`<PR>-<ROUND>-<HEAD_SHORT>`),可直接传给 fix。
43
58
  - 最多修复 2 次(防止无限循环):
44
- - 第 1 次:Task `pr-fix`(使用该 fixFile)→ 再 Task `pr-precheck`
59
+ - 第 1 次:Task `pr-fix`(传入 `fixFile`, `runId`, `round`)→ 再 Task `pr-precheck`
45
60
  - 若仍返回 `{"ok":false,"fixFile":"..."}`:第 2 次 Task `pr-fix` → 再 Task `pr-precheck`
46
61
  - 若仍不是 `{"ok":true}`:终止并回传错误(建议:`{"error":"PRECHECK_NOT_CLEAN_AFTER_FIX"}`)
47
-
62
+ - 注意:预检失败产生的修复也应记录在 Decision Log 中(essence: `__precheck__` 或具体错误信息,file: `__precheck__`),以便后续追踪。
48
63
 
49
64
  ## 循环(最多 3 轮)
50
65
 
@@ -61,7 +76,8 @@ agent: sisyphus
61
76
 
62
77
  - prompt 必须包含:`PR #{{PR_NUMBER}}`、`round: <ROUND>`
63
78
  - 若返回 `{"error":"..."}`:立即终止本轮并回传错误(不再调用 reviewers)
64
- - 取出:`contextFile`、`runId`、`headOid`(如有)
79
+ - 取出:`contextFile`、`runId`、`headOid`
80
+ - **runId 校验**:确认返回的 `runId` 符合 `<PR>-<ROUND>-<HEAD_SHORT>` 格式
65
81
  - **CRITICAL**: 必须等待此 Task 成功完成并获取到 `contextFile` 后,才能进入 Step 2
66
82
 
67
83
  **检查 Decision Log**:
@@ -77,14 +93,14 @@ agent: sisyphus
77
93
  - `round: <ROUND>`
78
94
  - `runId: <RUN_ID>`(来自 Step 1 的输出,必须透传,禁止自行生成)
79
95
  - `contextFile: ./.cache/<file>.md`(来自 Step 1 的输出)
80
- - `decisionLogFile: ./.cache/decision-log-pr{{PR_NUMBER}}.md`(如存在,来自检查后得出)
96
+ - `decisionLogFile: ./.cache/decision-log-pr{{PR_NUMBER}}.md`(如存在)
81
97
  - reviewer 默认读 `contextFile`;如果 `decisionLogFile` 存在,reviewer 应在 prompt 中提供该文件路径以参考前轮决策;必要时允许用 `git/gh` 只读命令拿 diff
82
98
  - 忽略问题:1.格式化代码引起的噪音 2.已经lint检查以外的格式问题 3.忽略单元测试不足的问题
83
99
  - 特别关注: 逻辑、安全、性能、可维护性
84
- - 同时要注意 pr 前面轮次的 修复和讨论,对于已经拒绝、已修复的问题不要反复的提出
85
- - 同时也要注意fix的过程中有没有引入新的问题。
86
-
87
- 备注:fixFile 分为 `IssuesToFix`(P0/P1,必须修)与 `OptionalIssues`(P2/P3,pr-fix 自主裁决)。
100
+ - 遵守 Decision Log:
101
+ - 已修复(Fixed):不再提
102
+ - 已拒绝(Rejected):除非优先级升级(P_new - P_old >= 2),否则不再提
103
+ - 任何新发现必须基于当前 `runId` 对应的代码状态
88
104
  - 每个 reviewer 输出:`reviewFile: ./.cache/<file>.md`(Markdown)
89
105
 
90
106
  3. Task: `pr-review-aggregate`
@@ -93,6 +109,10 @@ agent: sisyphus
93
109
  - 输出:`{"stop":true}` 或 `{"stop":false,"fixFile":"..."}`
94
110
  - 若 `stop=true`:本轮结束并退出循环
95
111
  - **唯一性约束**: 每轮只能发布一次 Review Summary;脚本内置幂等检查,重复调用不会重复发布
112
+ - 智能聚合:
113
+ - 使用 LLM 对比 decision-log 中的 `essence` 与新 finding
114
+ - 仅当问题本质相同且优先级 delta < 2 时,自动归为 Repeated/Ignored
115
+ - 否则视为 New Issue 或 Escalation
96
116
 
97
117
  4. Task: `pr-fix`
98
118
 
@@ -102,9 +122,13 @@ agent: sisyphus
102
122
  - `runId: <RUN_ID>`(来自 Step 1 的输出,必须透传,禁止自行生成)
103
123
  - `fixFile: ./.cache/<file>.md`
104
124
  - 约定:`pr-fix` 对每个 findingId 单独 commit + push(一个 findingId 一个 commit),结束后再 `git push` 兜底
105
-
125
+ - 决策记录:
126
+ - 修复成功:追加 Fixed 记录(含 `essence`)到 Decision Log
127
+ - 拒绝/无法修复:追加 Rejected 记录(含 `reason`, `essence`)到 Decision Log
128
+ - 范围限制:essence 匹配必须在 **同一个文件** 内(不支持跨文件/重命名追踪)
106
129
  - pr-fix 输出:`fixReportFile: ./.cache/<file>.md`(Markdown)
107
130
 
131
+
108
132
  5. Task: `pr-review-aggregate`(发布修复评论)
109
133
 
110
134
  - prompt 必须包含:`PR #{{PR_NUMBER}}`、`round: <ROUND>`、`runId: <RUN_ID>`、`fixReportFile: ./.cache/<file>.md`
@@ -119,6 +143,32 @@ agent: sisyphus
119
143
 
120
144
  - 回到 1(进入下一轮 reviewers)
121
145
 
146
+ ## 本地验证(脚本直跑)
147
+
148
+ ```bash
149
+ # 0) 先确保 gh 已认证(host 从 git remote origin 推断;必要时用 --hostname)
150
+ gh auth status
151
+
152
+ # 1) precheck(round 1)
153
+ python3 "./@opencode/agents/pr_precheck.py" --pr <PR_NUMBER> --round 1
154
+
155
+ # 2) context(round 1)
156
+ python3 "./@opencode/agents/pr_context.py" --pr <PR_NUMBER> --round 1
157
+
158
+ # 3) 校验:两者都必须输出单行 JSON,且 runId 必须一致
159
+ python3 "./@opencode/agents/pr_precheck.py" --pr <PR_NUMBER> --round 1 > ./.cache/_precheck.json
160
+ python3 "./@opencode/agents/pr_context.py" --pr <PR_NUMBER> --round 1 > ./.cache/_context.json
161
+ python3 - <<'PY'
162
+ import json
163
+ p=json.load(open('./.cache/_precheck.json'))
164
+ c=json.load(open('./.cache/_context.json'))
165
+ assert p.get('runId') == c.get('runId'), (p.get('runId'), c.get('runId'))
166
+ print('OK', p.get('runId'))
167
+ PY
168
+
169
+ # 4) 运行脚本相关测试(注意:pytest 把 @ 当作 argfile;必须加 ./ 并加引号)
170
+ python3 -m pytest -q "./@opencode/agents/test_pr_review_aggregate.py"
171
+ ```
122
172
 
123
173
  ## 终止与收尾(强制)
124
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ranger1/dx",
3
- "version": "0.1.48",
3
+ "version": "0.1.49",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {