@ranger1/dx 0.1.90 → 0.1.92

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.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/lib/cli/commands/core.js +92 -0
  3. package/lib/cli/help.js +2 -1
  4. package/lib/codex-initial.js +19 -215
  5. package/package.json +2 -2
  6. package/skills/backend-layering-audit-fixer/SKILL.md +180 -0
  7. package/{codex/skills → skills}/doctor/SKILL.md +2 -9
  8. package/{codex/skills → skills}/doctor/scripts/doctor.sh +2 -253
  9. package/skills/git-pr-ship/SKILL.md +481 -0
  10. package/skills/naming-audit-fixer/SKILL.md +149 -0
  11. package/skills/naming-audit-fixer/references/fix-guide.md +93 -0
  12. package/skills/naming-audit-fixer/scripts/audit_naming.py +534 -0
  13. package/codex/agents/fixer.toml +0 -37
  14. package/codex/agents/orchestrator.toml +0 -11
  15. package/codex/agents/reviewer.toml +0 -52
  16. package/codex/agents/spark.toml +0 -18
  17. package/codex/skills/pr-review-loop/SKILL.md +0 -209
  18. package/codex/skills/pr-review-loop/agents/openai.yaml +0 -4
  19. package/codex/skills/pr-review-loop/references/agents/pr-context.md +0 -73
  20. package/codex/skills/pr-review-loop/references/agents/pr-precheck.md +0 -161
  21. package/codex/skills/pr-review-loop/references/agents/pr-review-aggregate.md +0 -188
  22. package/codex/skills/pr-review-loop/references/skill-layout.md +0 -25
  23. package/codex/skills/pr-review-loop/scripts/gh_review_harvest.py +0 -292
  24. package/codex/skills/pr-review-loop/scripts/pr_context.py +0 -351
  25. package/codex/skills/pr-review-loop/scripts/pr_review_aggregate.py +0 -951
  26. package/codex/skills/pr-review-loop/scripts/test_pr_review_aggregate.py +0 -876
  27. package/codex/skills/pr-review-loop/scripts/test_validate_reviewer_prompts.py +0 -92
  28. package/codex/skills/pr-review-loop/scripts/validate_reviewer_prompts.py +0 -87
  29. /package/{codex/skills → skills}/doctor/agents/openai.yaml +0 -0
  30. /package/{codex/skills → skills}/e2e-audit-fixer/SKILL.md +0 -0
  31. /package/{codex/skills → skills}/e2e-audit-fixer/agents/openai.yaml +0 -0
  32. /package/{codex/skills → skills}/e2e-audit-fixer/scripts/e2e_e2e_audit.py +0 -0
  33. /package/{codex/skills → skills}/env-accessor-audit-fixer/SKILL.md +0 -0
  34. /package/{codex/skills → skills}/env-accessor-audit-fixer/agents/openai.yaml +0 -0
  35. /package/{codex/skills → skills}/env-accessor-audit-fixer/references/bootstrap-env-foundation.md +0 -0
  36. /package/{codex/skills → skills}/env-accessor-audit-fixer/scripts/env_accessor_audit.py +0 -0
  37. /package/{codex/skills → skills}/error-handling-audit-fixer/SKILL.md +0 -0
  38. /package/{codex/skills → skills}/error-handling-audit-fixer/agents/openai.yaml +0 -0
  39. /package/{codex/skills → skills}/error-handling-audit-fixer/references/error-handling-standard.md +0 -0
  40. /package/{codex/skills → skills}/error-handling-audit-fixer/references/foundation-bootstrap.md +0 -0
  41. /package/{codex/skills → skills}/error-handling-audit-fixer/scripts/error_handling_audit.py +0 -0
  42. /package/{codex/skills → skills}/gh-dependabot-cleanup/SKILL.md +0 -0
  43. /package/{codex/skills → skills}/gh-dependabot-cleanup/agents/openai.yaml +0 -0
  44. /package/{codex/skills → skills}/git-commit-and-pr/SKILL.md +0 -0
  45. /package/{codex/skills → skills}/git-commit-and-pr/agents/openai.yaml +0 -0
  46. /package/{codex/skills → skills}/git-release/SKILL.md +0 -0
  47. /package/{codex/skills → skills}/git-release/agents/openai.yaml +0 -0
  48. /package/{codex/skills → skills}/online-debug-guard/SKILL.md +0 -0
  49. /package/{codex/skills → skills}/online-debug-guard/agents/openai.yaml +0 -0
  50. /package/{codex/skills → skills}/pagination-dto-audit-fixer/SKILL.md +0 -0
  51. /package/{codex/skills → skills}/pagination-dto-audit-fixer/agents/openai.yaml +0 -0
  52. /package/{codex/skills → skills}/pagination-dto-audit-fixer/references/pagination-standard.md +0 -0
  53. /package/{codex/skills → skills}/pagination-dto-audit-fixer/scripts/pagination_dto_audit.py +0 -0
@@ -1,876 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Unit tests for pr_review_aggregate.py decision log parsing and filtering.
4
-
5
- Tests cover:
6
- 1. _parse_decision_log() - parsing markdown decision logs
7
- 2. _filter_by_decision_log() - filtering findings based on prior decisions
8
- 3. Edge cases: empty input, malformed data, cross-reviewer matching
9
- """
10
-
11
- import importlib.util
12
- import json
13
- import subprocess
14
- from collections.abc import Mapping, Sequence
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
30
-
31
-
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
- _parse_review_findings = cast(
48
- Callable[[str], list[dict[str, object]]],
49
- getattr(_pr_review_aggregate, "_parse_review_findings"),
50
- )
51
- _parse_aggregate_result_json = cast(
52
- Callable[[str], dict[str, object] | None],
53
- getattr(_pr_review_aggregate, "_parse_aggregate_result_json"),
54
- )
55
- _check_existing_comment = cast(
56
- Callable[[int, str, int, str], bool],
57
- getattr(_pr_review_aggregate, "_check_existing_comment"),
58
- )
59
- _MARKER = cast(str, getattr(_pr_review_aggregate, "MARKER"))
60
-
61
-
62
- # ============================================================
63
- # Fixtures
64
- # ============================================================
65
-
66
- @pytest.fixture
67
- def empty_decision_log() -> str:
68
- """Empty decision log markdown."""
69
- return ""
70
-
71
-
72
- @pytest.fixture
73
- def valid_decision_log() -> str:
74
- """Valid decision log with Fixed and Rejected entries."""
75
- return """# Decision Log
76
-
77
- PR: 123
78
-
79
- ## Round 1
80
-
81
- ### Fixed
82
- - id: SEC-001
83
- file: apps/backend/src/api.ts
84
- commit: abc123
85
- essence: JSON.parse 未捕获异常
86
-
87
- - id: STY-002
88
- file: apps/front/src/ErrorBoundary.tsx
89
- commit: def456
90
- essence: 缺少错误边界处理
91
-
92
- ### Rejected
93
- - id: STY-004
94
- file: apps/front/src/Component.tsx
95
- priority: P2
96
- reason: 需要产品决策,超出 PR 范围
97
- essence: 组件拆分建议
98
-
99
- - id: LOG-003
100
- file: apps/backend/src/db.ts
101
- priority: P3
102
- reason: 性能优化非当前优先级
103
- essence: 批量查询优化
104
- """
105
-
106
-
107
- @pytest.fixture
108
- def valid_decision_log_legacy_no_file() -> str:
109
- """Legacy decision log fixture without the file: field (backward compat)."""
110
- return """# Decision Log
111
-
112
- PR: 123
113
-
114
- ## Round 1
115
-
116
- ### Fixed
117
- - id: SEC-001
118
- commit: abc123
119
- essence: JSON.parse 未捕获异常
120
-
121
- - id: STY-002
122
- commit: def456
123
- essence: 缺少错误边界处理
124
-
125
- ### Rejected
126
- - id: STY-004
127
- priority: P2
128
- reason: 需要产品决策,超出 PR 范围
129
- essence: 组件拆分建议
130
-
131
- - id: LOG-003
132
- priority: P3
133
- reason: 性能优化非当前优先级
134
- essence: 批量查询优化
135
- """
136
-
137
-
138
- @pytest.fixture
139
- def malformed_decision_log() -> str:
140
- """Malformed decision log with missing fields and bad formatting."""
141
- return """# Decision Log
142
-
143
- PR: 123
144
-
145
- ### Fixed
146
- - id: BROKEN-001
147
- # Missing essence field
148
-
149
- ### Rejected
150
- - id: BROKEN-002
151
- priority: P2
152
- # Missing essence and reason
153
-
154
- Some random text that should be ignored
155
-
156
- - id: BROKEN-003
157
- this is not a valid field format
158
- """
159
-
160
-
161
- @pytest.fixture
162
- def sample_findings() -> list[dict[str, object]]:
163
- """Sample findings list for filter tests."""
164
- return [
165
- {
166
- "id": "SEC-001",
167
- "priority": "P1",
168
- "category": "bug",
169
- "file": "api.ts",
170
- "line": "42",
171
- "title": "JSON parse error",
172
- "description": "JSON.parse 未捕获异常",
173
- "suggestion": "Add try-catch"
174
- },
175
- {
176
- "id": "STY-004",
177
- "priority": "P2",
178
- "category": "quality",
179
- "file": "Component.tsx",
180
- "line": "100",
181
- "title": "Component split",
182
- "description": "组件拆分建议",
183
- "suggestion": "Split into smaller components"
184
- },
185
- {
186
- "id": "LOG-007",
187
- "priority": "P0",
188
- "category": "bug",
189
- "file": "Component.tsx",
190
- "line": "100",
191
- "title": "Component split (escalated)",
192
- "description": "组件拆分建议 - 升级为 P0",
193
- "suggestion": "Split into smaller components - critical"
194
- },
195
- {
196
- "id": "NEW-001",
197
- "priority": "P1",
198
- "category": "bug",
199
- "file": "utils.ts",
200
- "line": "20",
201
- "title": "New issue",
202
- "description": "This is a new issue",
203
- "suggestion": "Fix it"
204
- }
205
- ]
206
-
207
-
208
- @pytest.fixture
209
- def prior_decisions() -> list[dict[str, object]]:
210
- """Sample prior decisions from _parse_decision_log."""
211
- return [
212
- {
213
- "id": "SEC-001",
214
- "status": "fixed",
215
- "commit": "abc123",
216
- "essence": "JSON.parse 未捕获异常"
217
- },
218
- {
219
- "id": "STY-004",
220
- "status": "rejected",
221
- "priority": "P2",
222
- "reason": "需要产品决策,超出 PR 范围",
223
- "essence": "组件拆分建议"
224
- }
225
- ]
226
-
227
-
228
- # ============================================================
229
- # Test: _parse_decision_log() - Empty Input
230
- # ============================================================
231
-
232
- def test_parse_decision_log_empty(empty_decision_log: str) -> None:
233
- """
234
- Test that empty decision log returns empty list.
235
-
236
- Given: empty string
237
- When: _parse_decision_log() is called
238
- Then: returns []
239
- """
240
- result = _parse_decision_log(empty_decision_log)
241
- assert result == []
242
- assert isinstance(result, list)
243
-
244
-
245
- # ============================================================
246
- # Test: _parse_decision_log() - Valid Input
247
- # ============================================================
248
-
249
- def test_parse_decision_log_valid(valid_decision_log: str) -> None:
250
- """
251
- Test that valid decision log is parsed into structured data.
252
-
253
- Given: valid markdown with Fixed and Rejected sections
254
- When: _parse_decision_log() is called
255
- Then: returns list of dicts with id, status, essence, and optional fields
256
- """
257
- result = _parse_decision_log(valid_decision_log)
258
-
259
- # Should have 4 entries (2 Fixed, 2 Rejected)
260
- assert len(result) == 4
261
-
262
- # Verify first Fixed entry
263
- fixed_1 = result[0]
264
- assert fixed_1["id"] == "SEC-001"
265
- assert fixed_1["status"] == "fixed"
266
- assert fixed_1["file"] == "apps/backend/src/api.ts"
267
- assert fixed_1["commit"] == "abc123"
268
- assert fixed_1["essence"] == "JSON.parse 未捕获异常"
269
-
270
- # Verify second Fixed entry
271
- fixed_2 = result[1]
272
- assert fixed_2["id"] == "STY-002"
273
- assert fixed_2["status"] == "fixed"
274
- assert fixed_2["file"] == "apps/front/src/ErrorBoundary.tsx"
275
- assert fixed_2["commit"] == "def456"
276
- assert fixed_2["essence"] == "缺少错误边界处理"
277
-
278
- # Verify first Rejected entry
279
- rejected_1 = result[2]
280
- assert rejected_1["id"] == "STY-004"
281
- assert rejected_1["status"] == "rejected"
282
- assert rejected_1["file"] == "apps/front/src/Component.tsx"
283
- assert rejected_1["priority"] == "P2"
284
- assert rejected_1["reason"] == "需要产品决策,超出 PR 范围"
285
- assert rejected_1["essence"] == "组件拆分建议"
286
-
287
- # Verify second Rejected entry
288
- rejected_2 = result[3]
289
- assert rejected_2["id"] == "LOG-003"
290
- assert rejected_2["status"] == "rejected"
291
- assert rejected_2["file"] == "apps/backend/src/db.ts"
292
- assert rejected_2["priority"] == "P3"
293
-
294
-
295
- def test_parse_decision_log_legacy_without_file(valid_decision_log_legacy_no_file: str) -> None:
296
- """Decision log entries without file: should still parse (backward compat)."""
297
- result = _parse_decision_log(valid_decision_log_legacy_no_file)
298
-
299
- # Should have 4 entries (2 Fixed, 2 Rejected)
300
- assert len(result) == 4
301
-
302
- # Basic shape should still be present
303
- for entry in result:
304
- assert "id" in entry
305
- assert "status" in entry
306
-
307
- # And file should be optional
308
- assert all(("file" not in e) or (e["file"] in (None, "")) for e in result)
309
-
310
-
311
- # ============================================================
312
- # Test: _parse_decision_log() - Malformed Input
313
- # ============================================================
314
-
315
- def test_parse_decision_log_malformed(malformed_decision_log: str) -> None:
316
- """
317
- Test that malformed decision log degrades gracefully.
318
-
319
- Given: decision log with missing required fields
320
- When: _parse_decision_log() is called
321
- Then: returns partial data without raising exceptions
322
- """
323
- # Should not raise exception
324
- result = _parse_decision_log(malformed_decision_log)
325
-
326
- # Should return some data (even if incomplete)
327
- assert isinstance(result, list)
328
-
329
- # Entries should have at least id and status
330
- for entry in result:
331
- assert "id" in entry
332
- assert "status" in entry
333
-
334
-
335
- # ============================================================
336
- # Test: _filter_by_decision_log() - Fixed Issues
337
- # ============================================================
338
-
339
- def test_filter_fixed_issues(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
340
- """
341
- Test that findings matching Fixed decisions are filtered out.
342
-
343
- Given: findings containing SEC-001 which is in Fixed decisions
344
- When: _filter_by_decision_log() is called with empty escalation_groups
345
- Then: SEC-001 is filtered out
346
- """
347
- escalation_groups: list[list[str]] = []
348
-
349
- result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
350
-
351
- # SEC-001 should be filtered (it's in Fixed decisions)
352
- result_ids = [f["id"] for f in result]
353
- assert "SEC-001" not in result_ids
354
-
355
- # Other findings should remain
356
- assert "STY-004" in result_ids or "LOG-007" in result_ids or "NEW-001" in result_ids
357
-
358
-
359
- # ============================================================
360
- # Test: _filter_by_decision_log() - Rejected Without Escalation
361
- # ============================================================
362
-
363
- def test_filter_rejected_without_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
364
- """
365
- Test that findings matching Rejected decisions are filtered out when NOT in escalation_groups.
366
-
367
- Given: findings containing STY-004 which is in Rejected decisions
368
- and escalation_groups is empty
369
- When: _filter_by_decision_log() is called
370
- Then: STY-004 is filtered out
371
- """
372
- escalation_groups: list[list[str]] = []
373
-
374
- result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
375
-
376
- # STY-004 should be filtered (it's Rejected and not escalated)
377
- result_ids = [f["id"] for f in result]
378
- assert "STY-004" not in result_ids
379
-
380
- # New findings should remain
381
- assert "NEW-001" in result_ids
382
-
383
-
384
- # ============================================================
385
- # Test: _filter_by_decision_log() - Rejected With Escalation
386
- # ============================================================
387
-
388
- def test_filter_rejected_with_escalation(sample_findings: list[dict[str, object]], prior_decisions: list[dict[str, object]]) -> None:
389
- """
390
- Test that findings matching Rejected decisions are kept when in escalation_groups.
391
-
392
- Given: findings containing LOG-007 which is an escalation of STY-004
393
- and escalation_groups contains ["STY-004", "LOG-007"]
394
- When: _filter_by_decision_log() is called
395
- Then: LOG-007 is NOT filtered (it's an escalation)
396
- """
397
- # STY-004 (Rejected P2) -> LOG-007 (escalated to P0, ≥2 level jump)
398
- escalation_groups = [["STY-004", "LOG-007"]]
399
-
400
- result = _filter_by_decision_log(sample_findings, prior_decisions, escalation_groups)
401
-
402
- # LOG-007 should NOT be filtered (it's an escalation)
403
- result_ids = [f["id"] for f in result]
404
- assert "LOG-007" in result_ids
405
-
406
- # STY-004 itself (P2) should still be filtered
407
- assert "STY-004" not in result_ids
408
-
409
-
410
- # ============================================================
411
- # Test: _filter_by_decision_log() - Cross-Reviewer Match
412
- # ============================================================
413
-
414
- def test_filter_cross_reviewer_match() -> None:
415
- """
416
- Test that findings with different reviewer IDs but same essence are filtered.
417
-
418
- Given: findings containing STY-005 (different ID from SEC-001)
419
- but prior decisions contain SEC-001 as Fixed
420
- and escalation_groups links them: ["SEC-001", "STY-005"]
421
- When: _filter_by_decision_log() is called
422
- Then: STY-005 is filtered (matched via escalation group to Fixed decision)
423
- """
424
- findings = [
425
- {
426
- "id": "STY-005",
427
- "priority": "P1",
428
- "category": "bug",
429
- "file": "api.ts",
430
- "line": "42",
431
- "title": "JSON parse error",
432
- "description": "JSON.parse 未捕获异常 (same essence as SEC-001)",
433
- "suggestion": "Add try-catch"
434
- },
435
- {
436
- "id": "NEW-002",
437
- "priority": "P2",
438
- "category": "quality",
439
- "file": "utils.ts",
440
- "line": "10",
441
- "title": "Different issue",
442
- "description": "Completely different",
443
- "suggestion": "Fix differently"
444
- }
445
- ]
446
-
447
- prior_decisions = [
448
- {
449
- "id": "SEC-001",
450
- "status": "fixed",
451
- "commit": "abc123",
452
- "essence": "JSON.parse 未捕获异常"
453
- }
454
- ]
455
-
456
- # Escalation group indicates STY-005 is related to SEC-001
457
- escalation_groups = [["SEC-001", "STY-005"]]
458
-
459
- result = _filter_by_decision_log(findings, prior_decisions, escalation_groups)
460
-
461
- # STY-005 should be filtered (linked to Fixed SEC-001 via escalation group)
462
- result_ids = [f["id"] for f in result]
463
- assert "STY-005" not in result_ids
464
-
465
- # NEW-002 should remain
466
- assert "NEW-002" in result_ids
467
-
468
-
469
- # ============================================================
470
- # Test: _parse_escalation_groups_json()
471
- # ============================================================
472
-
473
- def test_parse_escalation_groups_json_valid() -> None:
474
- """Test parsing valid escalation groups JSON."""
475
- json_str = '{"escalationGroups": [["STY-004", "LOG-007"], ["SEC-001", "STY-005"]]}'
476
- result = _parse_escalation_groups_json(json_str)
477
-
478
- assert len(result) == 2
479
- assert ["STY-004", "LOG-007"] in result
480
- assert ["SEC-001", "STY-005"] in result
481
-
482
-
483
- def test_parse_escalation_groups_json_empty() -> None:
484
- """Test parsing empty escalation groups JSON."""
485
- result = _parse_escalation_groups_json("")
486
- assert result == []
487
-
488
-
489
- def test_parse_escalation_groups_json_malformed() -> None:
490
- """Test parsing malformed JSON returns empty list."""
491
- result = _parse_escalation_groups_json("not valid json {{{")
492
- assert result == []
493
-
494
-
495
- # ============================================================
496
- # Test: _parse_escalation_groups_b64()
497
- # ============================================================
498
-
499
- def test_parse_escalation_groups_b64_valid() -> None:
500
- """Test parsing valid base64-encoded escalation groups."""
501
- import base64
502
- json_str = '{"escalationGroups": [["STY-004", "LOG-007"]]}'
503
- b64_str = base64.b64encode(json_str.encode("utf-8")).decode("ascii")
504
-
505
- result = _parse_escalation_groups_b64(b64_str)
506
-
507
- assert len(result) == 1
508
- assert ["STY-004", "LOG-007"] in result
509
-
510
-
511
- def test_parse_escalation_groups_b64_empty() -> None:
512
- """Test parsing empty base64 string."""
513
- result = _parse_escalation_groups_b64("")
514
- assert result == []
515
-
516
-
517
- def test_parse_escalation_groups_b64_invalid() -> None:
518
- """Test parsing invalid base64 returns empty list."""
519
- result = _parse_escalation_groups_b64("not-valid-base64!!!")
520
- assert result == []
521
-
522
-
523
- # ============================================================
524
- # Test: _parse_review_findings()
525
- # ============================================================
526
-
527
- def test_parse_review_findings_supports_current_reviewer_format() -> None:
528
- """Current reviewer output uses plain key-value finding blocks."""
529
- review_md = """# Review (SEC)
530
- PR: 2934
531
- Round: 1
532
-
533
- ## Findings
534
- ### SEC-001
535
- id: SEC-001
536
- priority: P1
537
- category: Path Traversal
538
- file: scripts/release/backend-deploy-release.sh
539
- line: 128
540
- title: 发布目录名未校验导致路径穿越与高危删除
541
- description: 说明文本
542
- suggestion: 修复建议
543
- """
544
- result = _parse_review_findings(review_md)
545
- assert len(result) == 1
546
- assert result[0]["id"] == "SEC-001"
547
- assert result[0]["priority"] == "P1"
548
- assert result[0]["file"] == "scripts/release/backend-deploy-release.sh"
549
-
550
-
551
- def test_parse_review_findings_keeps_legacy_list_format() -> None:
552
- """Legacy list-style findings should remain parseable."""
553
- review_md = """## Findings
554
- - id: LOG-001
555
- priority: P1
556
- category: logic
557
- file: apps/api/src/service.ts
558
- line: 10
559
- title: 标题
560
- description: 描述
561
- suggestion: 建议
562
- """
563
- result = _parse_review_findings(review_md)
564
- assert len(result) == 1
565
- assert result[0]["id"] == "LOG-001"
566
- assert result[0]["priority"] == "P1"
567
-
568
-
569
- # ============================================================
570
- # Test: _parse_aggregate_result_json()
571
- # ============================================================
572
-
573
- def test_parse_aggregate_result_json_valid() -> None:
574
- """模型给出的聚合结果应被脚本按结构化数据接收。"""
575
- raw = json.dumps(
576
- {
577
- "stop": False,
578
- "mustFixFindings": [
579
- {
580
- "id": "SEC-001",
581
- "priority": "P1",
582
- "category": "bug",
583
- "file": "apps/api/src/service.ts",
584
- "line": "10",
585
- "title": "标题",
586
- "description": "描述",
587
- "suggestion": "建议",
588
- }
589
- ],
590
- "optionalFindings": [
591
- {
592
- "id": "STY-002",
593
- "priority": "P3",
594
- "category": "quality",
595
- "file": "apps/web/src/page.tsx",
596
- "line": "22",
597
- "title": "样式建议",
598
- "description": "描述",
599
- "suggestion": "建议",
600
- }
601
- ],
602
- },
603
- ensure_ascii=True,
604
- )
605
-
606
- result = _parse_aggregate_result_json(raw)
607
-
608
- assert result is not None
609
- assert result["stop"] is False
610
- assert len(cast(list[dict[str, object]], result["mustFixFindings"])) == 1
611
- assert len(cast(list[dict[str, object]], result["optionalFindings"])) == 1
612
-
613
-
614
- def test_parse_aggregate_result_json_rejects_missing_required_fields() -> None:
615
- """缺关键字段时必须失败,不能静默降级成 stop=true。"""
616
- raw = json.dumps(
617
- {
618
- "stop": False,
619
- "mustFixFindings": [
620
- {
621
- "id": "SEC-001",
622
- "file": "apps/api/src/service.ts",
623
- "line": "10",
624
- "title": "标题",
625
- "description": "描述",
626
- "suggestion": "建议",
627
- }
628
- ],
629
- "optionalFindings": [],
630
- },
631
- ensure_ascii=True,
632
- )
633
-
634
- result = _parse_aggregate_result_json(raw)
635
- assert result is None
636
-
637
-
638
- def test_parse_aggregate_result_json_rejects_stop_true_with_must_fix() -> None:
639
- """stop=true 时不允许同时带 must-fix,避免协议自相矛盾。"""
640
- raw = json.dumps(
641
- {
642
- "stop": True,
643
- "mustFixFindings": [
644
- {
645
- "id": "SEC-009",
646
- "priority": "P0",
647
- "category": "bug",
648
- "file": "apps/api/src/service.ts",
649
- "line": "99",
650
- "title": "标题",
651
- "description": "描述",
652
- "suggestion": "建议",
653
- }
654
- ],
655
- "optionalFindings": [],
656
- },
657
- ensure_ascii=True,
658
- )
659
-
660
- result = _parse_aggregate_result_json(raw)
661
- assert result is None
662
-
663
-
664
- def test_parse_aggregate_result_json_rejects_wrong_bucket_priority() -> None:
665
- """must-fix 与 optional 的优先级分桶必须严格一致。"""
666
- raw = json.dumps(
667
- {
668
- "stop": False,
669
- "mustFixFindings": [
670
- {
671
- "id": "STY-010",
672
- "priority": "P2",
673
- "category": "quality",
674
- "file": "apps/api/src/service.ts",
675
- "line": "12",
676
- "title": "标题",
677
- "description": "描述",
678
- "suggestion": "建议",
679
- }
680
- ],
681
- "optionalFindings": [],
682
- },
683
- ensure_ascii=True,
684
- )
685
-
686
- result = _parse_aggregate_result_json(raw)
687
- assert result is None
688
-
689
-
690
- # ============================================================
691
- # Test: Integration - Full Workflow
692
- # ============================================================
693
-
694
- def test_integration_full_filter_workflow() -> None:
695
- """
696
- Integration test: parse decision log and filter findings.
697
-
698
- Simulates real workflow:
699
- 1. Parse decision log markdown
700
- 2. Parse escalation groups
701
- 3. Filter findings based on decisions and escalations
702
- """
703
- decision_log_md = """# Decision Log
704
-
705
- PR: 456
706
-
707
- ## Round 1
708
-
709
- ### Fixed
710
- - id: SEC-010
711
- commit: sha1
712
- essence: 类型错误修复
713
-
714
- ### Rejected
715
- - id: STY-020
716
- priority: P3
717
- reason: 低优先级优化
718
- essence: 性能优化建议
719
- """
720
-
721
- findings = [
722
- {"id": "SEC-010", "priority": "P1", "category": "bug", "file": "a.ts", "line": "1", "title": "Type error", "description": "类型错误修复", "suggestion": "Fix"},
723
- {"id": "STY-020", "priority": "P3", "category": "perf", "file": "b.ts", "line": "2", "title": "Perf opt", "description": "性能优化建议", "suggestion": "Optimize"},
724
- {"id": "LOG-030", "priority": "P1", "category": "perf", "file": "b.ts", "line": "2", "title": "Perf opt escalated", "description": "性能优化建议 - 升级", "suggestion": "Optimize now"},
725
- {"id": "NEW-100", "priority": "P2", "category": "quality", "file": "c.ts", "line": "3", "title": "New", "description": "新问题", "suggestion": "Fix new"},
726
- ]
727
-
728
- # Parse decision log
729
- prior_decisions = _parse_decision_log(decision_log_md)
730
- assert len(prior_decisions) == 2
731
-
732
- # Escalation: STY-020 (P3) -> LOG-030 (P1, ≥2 level jump)
733
- escalation_groups = [["STY-020", "LOG-030"]]
734
-
735
- # Filter findings
736
- result = _filter_by_decision_log(findings, prior_decisions, escalation_groups)
737
- result_ids = [f["id"] for f in result]
738
-
739
- # SEC-010 should be filtered (Fixed)
740
- assert "SEC-010" not in result_ids
741
-
742
- # STY-020 should be filtered (Rejected, not escalated)
743
- assert "STY-020" not in result_ids
744
-
745
- # LOG-030 should remain (escalation of Rejected)
746
- assert "LOG-030" in result_ids
747
-
748
- # NEW-100 should remain (new issue)
749
- assert "NEW-100" in result_ids
750
-
751
- # Final count: 2 findings remain
752
- assert len(result) == 2
753
-
754
-
755
- # ============================================================
756
- # Test: _check_existing_comment() - PR comment idempotency
757
- # ============================================================
758
-
759
-
760
- def _patch_subprocess_run_for_gh_comments(monkeypatch: pytest.MonkeyPatch, comments: list[dict[str, object]], returncode: int = 0) -> None:
761
- stdout = json.dumps(comments, ensure_ascii=True)
762
-
763
- def _fake_run(*_args: object, **_kwargs: object) -> SimpleNamespace:
764
- return SimpleNamespace(returncode=returncode, stdout=stdout)
765
-
766
- monkeypatch.setattr(subprocess, "run", _fake_run)
767
-
768
-
769
- @pytest.mark.parametrize(
770
- "comment_type,round_num,expected_header",
771
- [
772
- ("review-summary", 2, "## Review Summary (Round 2)"),
773
- ("fix-report", 2, "## Fix Report (Round 2)"),
774
- ("final-report", 2, "## Final Report"),
775
- ],
776
- )
777
- def test_check_existing_comment_true_when_marker_header_and_runid_match(
778
- monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
779
- ) -> None:
780
- pr_number = 123
781
- run_id = "run-abc"
782
- body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {run_id}"])
783
- _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
784
-
785
- assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is True
786
-
787
-
788
- @pytest.mark.parametrize(
789
- "comment_type,round_num,expected_header",
790
- [
791
- ("review-summary", 3, "## Review Summary (Round 3)"),
792
- ("fix-report", 3, "## Fix Report (Round 3)"),
793
- ("final-report", 3, "## Final Report"),
794
- ],
795
- )
796
- def test_check_existing_comment_false_when_marker_missing(
797
- monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
798
- ) -> None:
799
- pr_number = 456
800
- run_id = "run-xyz"
801
- body = "\n".join(["", expected_header, "", f"RunId: {run_id}"])
802
- _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
803
-
804
- assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
805
-
806
-
807
- @pytest.mark.parametrize(
808
- "comment_type,round_num,expected_header,wrong_header",
809
- [
810
- (
811
- "review-summary",
812
- 2,
813
- "## Review Summary (Round 2)",
814
- "## Fix Report (Round 2)",
815
- ),
816
- (
817
- "fix-report",
818
- 2,
819
- "## Fix Report (Round 2)",
820
- "## Review Summary (Round 2)",
821
- ),
822
- (
823
- "final-report",
824
- 2,
825
- "## Final Report",
826
- "## Review Summary (Round 2)",
827
- ),
828
- ],
829
- )
830
- def test_check_existing_comment_false_when_header_mismatched(
831
- monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str, wrong_header: str
832
- ) -> None:
833
- pr_number = 789
834
- run_id = "run-123"
835
-
836
- body = "\n".join([_MARKER, "", wrong_header, "", f"RunId: {run_id}"])
837
- _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
838
-
839
- assert expected_header not in body
840
- assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
841
-
842
-
843
- @pytest.mark.parametrize(
844
- "comment_type,round_num,expected_header",
845
- [
846
- ("review-summary", 1, "## Review Summary (Round 1)"),
847
- ("fix-report", 1, "## Fix Report (Round 1)"),
848
- ("final-report", 1, "## Final Report"),
849
- ],
850
- )
851
- def test_check_existing_comment_false_when_runid_mismatched(
852
- monkeypatch: pytest.MonkeyPatch, comment_type: str, round_num: int, expected_header: str
853
- ) -> None:
854
- pr_number = 101
855
- run_id = "run-a"
856
- other_run_id = "run-b"
857
-
858
- body = "\n".join([_MARKER, "", expected_header, "", f"RunId: {other_run_id}"])
859
- _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}])
860
-
861
- assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
862
-
863
-
864
- def test_check_existing_comment_false_when_subprocess_run_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
865
- pr_number = 999
866
- run_id = "run-nonzero"
867
- round_num = 2
868
- comment_type = "review-summary"
869
- body = "\n".join([_MARKER, "", "## Review Summary (Round 2)", "", f"RunId: {run_id}"])
870
-
871
- _patch_subprocess_run_for_gh_comments(monkeypatch, [{"body": body}], returncode=1)
872
- assert _check_existing_comment(pr_number, run_id, round_num, comment_type) is False
873
-
874
-
875
- if __name__ == "__main__":
876
- _ = pytest.main([__file__, "-v"])