@ranger1/dx 0.1.88 → 0.1.90
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.
- package/codex/agents/reviewer.toml +13 -3
- package/codex/skills/pr-review-loop/SKILL.md +13 -2
- package/codex/skills/pr-review-loop/references/agents/pr-precheck.md +3 -0
- package/codex/skills/pr-review-loop/references/agents/pr-review-aggregate.md +66 -30
- package/codex/skills/pr-review-loop/scripts/pr_review_aggregate.py +115 -30
- package/codex/skills/pr-review-loop/scripts/test_pr_review_aggregate.py +125 -0
- package/codex/skills/pr-review-loop/scripts/test_validate_reviewer_prompts.py +92 -0
- package/codex/skills/pr-review-loop/scripts/validate_reviewer_prompts.py +87 -0
- package/lib/cli/commands/stack.js +15 -0
- package/package.json +1 -1
|
@@ -34,9 +34,19 @@ developer_instructions = '''
|
|
|
34
34
|
- `# Review (<ROLE_CODE>)`
|
|
35
35
|
- `PR: <PR_NUMBER>`
|
|
36
36
|
- `Round: <ROUND>`
|
|
37
|
-
-
|
|
38
|
-
- `## Findings
|
|
37
|
+
- `RunId: <RUN_ID>`
|
|
38
|
+
- `## Findings`
|
|
39
|
+
- 若无问题,`## Findings` 段内容必须仅为 `None`
|
|
40
|
+
- 若有问题,每条 finding 必须使用固定 key-value 块,字段为:
|
|
39
41
|
`id / priority / category / file / line / title / description / suggestion`
|
|
40
|
-
|
|
42
|
+
- 禁止输出 `## Summary`、表格、额外总结 prose、代码块或嵌套列表。
|
|
43
|
+
10. 输出前必须自检:
|
|
44
|
+
- `PR / Round / RunId` 与输入完全一致。
|
|
45
|
+
- 每个 finding 字段齐全且非空。
|
|
46
|
+
- `priority` 只能是 `P0 / P1 / P2 / P3`。
|
|
47
|
+
- `line` 必须是单个数字;未知时写 `null`。
|
|
48
|
+
- `id` 前缀与 `<ROLE_CODE>-` 一致。
|
|
49
|
+
11. 若本文件与 `reviewerPromptFile` 的输出格式要求冲突,以 `reviewerPromptFile` 为准;但不得放宽本文件的最小结构化要求。
|
|
50
|
+
12. 最终响应只输出一行:`reviewFile: ./.cache/<file>.md`。
|
|
41
51
|
|
|
42
52
|
'''
|
|
@@ -22,7 +22,7 @@ description: pr 审查
|
|
|
22
22
|
- 子代理说明:`${CODEX_HOME:-$HOME/.codex}/skills/pr-review-loop/references/agents/*.md`
|
|
23
23
|
- 确定性脚本:`${CODEX_HOME:-$HOME/.codex}/skills/pr-review-loop/scripts/*.py`
|
|
24
24
|
- 缓存目录:`./.cache/`
|
|
25
|
-
|
|
25
|
+
|
|
26
26
|
|
|
27
27
|
## 输入
|
|
28
28
|
|
|
@@ -101,6 +101,16 @@ reviewer 配置检测由 `pr-precheck` 执行,并且必须在其他预检动
|
|
|
101
101
|
--prompt: `${CODEX_HOME:-$HOME/.codex}/skills/pr-review-loop/references/agents/pr-review-aggregate.md`
|
|
102
102
|
--others: `contextFile + 1..n reviewFile + runId (+ decisionLogFile) + 模式 A`。
|
|
103
103
|
|
|
104
|
+
执行约束:
|
|
105
|
+
|
|
106
|
+
- `spark` 必须先读取 `contextFile` 与全部 `reviewFile`,由模型完成最终语义裁决。
|
|
107
|
+
- `spark` 必须自行判定:
|
|
108
|
+
- 是否 `stop`
|
|
109
|
+
- 哪些 finding 进入 `mustFixFindings`
|
|
110
|
+
- 哪些 finding 进入 `optionalFindings`
|
|
111
|
+
- `spark` 必须把上述裁决编码为结构化聚合结果,再交给脚本发布评论与生成 `fixFile`。
|
|
112
|
+
- 脚本在此步**不得**再根据 reviewer 原文自行推断“有没有问题”或 `stop`。
|
|
113
|
+
|
|
104
114
|
- 输出 `{"stop":true}`:进入 Step 7 本轮结束退出循环。
|
|
105
115
|
- 输出 `{"stop":false,"fixFile":"..."}`:进入 Step 4。
|
|
106
116
|
- 输出 `{"error":"GH_PR_COMMENT_FAILED"}`:按可重试错误处理,优先重试本步骤(脚本幂等,重复调用安全)。
|
|
@@ -194,5 +204,6 @@ final-report 由 `aggregator` 调用脚本发布,且幂等。
|
|
|
194
204
|
|
|
195
205
|
执行原则:
|
|
196
206
|
- 优先调用 `${CODEX_HOME:-$HOME/.codex}/skills/pr-review-loop/scripts/*.py` 作为确定性真值源。
|
|
197
|
-
-
|
|
207
|
+
- 但“是否有问题 / 是否 stop / finding 分级”属于模型语义裁决,不得再由脚本根据 reviewer 原文二次猜测。
|
|
208
|
+
- reviewer 或 aggregate 产物若缺少关键字段,必须失败并显式报错;禁止静默降级为“无问题”。
|
|
198
209
|
- 对外输出只保留关键状态、产物路径与下一步动作。
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
- 项目根目录必须存在 `./reviewer/` 目录。
|
|
21
21
|
- `./reviewer/` 下必须至少有一个 `*-reviewer.md` 文件。
|
|
22
22
|
- 每个 `*-reviewer.md` 文件都必须包含 `ROLE_CODE = <CODE>`(例如 `ROLE_CODE = STY`)。
|
|
23
|
+
- 必须执行:
|
|
24
|
+
- `python3 "${CODEX_HOME:-$HOME/.codex}/skills/pr-review-loop/scripts/validate_reviewer_prompts.py" ./reviewer/*-reviewer.md`
|
|
25
|
+
- 若脚本返回非零或输出 `ok != true`,视为 reviewer 输出契约不合法。
|
|
23
26
|
- 若上述任一条件不满足,立即终止并返回:`{"error":"REVIEWER_CONFIG_INVALID","detail":"..."}`。
|
|
24
27
|
|
|
25
28
|
2. 参数校验
|
|
@@ -45,26 +45,78 @@ runId: 123-1-a1b2c3d
|
|
|
45
45
|
|
|
46
46
|
## 执行方式(强制)
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
所有确定性工作(发评论/生成 fixFile/输出 JSON)都由 `${CODEX_HOME:-$HOME/.codex}/skills/pr-review-loop/scripts/pr_review_aggregate.py` 完成。
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
你只做三件事:
|
|
51
51
|
|
|
52
|
-
1) 在模式 A
|
|
53
|
-
2)
|
|
52
|
+
1) 在模式 A 里读取 `contextFile`、所有 `reviewFile`,由大模型做最终语义裁决。
|
|
53
|
+
2) 产出一份**结构化聚合结果 JSON**,再把它作为参数传给脚本(不落盘)。
|
|
54
|
+
3) 调用脚本后,把脚本 stdout 的 JSON **原样返回**给调用者(不做解释/分析)。
|
|
54
55
|
|
|
55
|
-
##
|
|
56
|
+
## 最终裁决边界(强制)
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
- “是否存在问题”“哪些问题必须修”“是否可以 stop” 都由你基于 `reviewFile` 语义判断。
|
|
59
|
+
- 脚本**不再**根据 reviewer 文本自行推断 `P0/P1`、`stop` 或“有没有问题”。
|
|
60
|
+
- 如果 reviewer 文本里缺少关键信息,导致你无法可靠裁决,必须返回错误;禁止把不确定性伪装成 `stop=true`。
|
|
58
61
|
|
|
59
|
-
|
|
62
|
+
## 聚合结果 JSON(模式 A 必须生成)
|
|
63
|
+
|
|
64
|
+
你需要基于 `contextFile`、所有 `reviewFile`,先完成这些判断:
|
|
65
|
+
|
|
66
|
+
- 去重:本质相同的问题只保留一条
|
|
67
|
+
- decision-log 过滤:已 fixed 的问题过滤;已 rejected 的问题仅在满足升级质疑条件时保留
|
|
68
|
+
- 分级:把保留的问题分成 `mustFixFindings` 和 `optionalFindings`
|
|
69
|
+
- 终止判断:仅当你确认没有 `mustFixFindings` 时,才可令 `stop=true`
|
|
70
|
+
|
|
71
|
+
然后输出一行 JSON,结构固定如下:
|
|
60
72
|
|
|
61
73
|
```json
|
|
62
|
-
{
|
|
74
|
+
{
|
|
75
|
+
"stop": false,
|
|
76
|
+
"mustFixFindings": [
|
|
77
|
+
{
|
|
78
|
+
"id": "SEC-001",
|
|
79
|
+
"priority": "P1",
|
|
80
|
+
"category": "bug",
|
|
81
|
+
"file": "apps/api/src/service.ts",
|
|
82
|
+
"line": "10",
|
|
83
|
+
"title": "标题",
|
|
84
|
+
"description": "描述",
|
|
85
|
+
"suggestion": "建议"
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
"optionalFindings": [
|
|
89
|
+
{
|
|
90
|
+
"id": "STY-002",
|
|
91
|
+
"priority": "P3",
|
|
92
|
+
"category": "quality",
|
|
93
|
+
"file": "apps/web/src/page.tsx",
|
|
94
|
+
"line": "22",
|
|
95
|
+
"title": "标题",
|
|
96
|
+
"description": "描述",
|
|
97
|
+
"suggestion": "建议"
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
}
|
|
63
101
|
```
|
|
64
102
|
|
|
103
|
+
强约束:
|
|
104
|
+
|
|
105
|
+
- 必须是一行 JSON,不要代码块,不要解释。
|
|
106
|
+
- `stop=true` 时,`mustFixFindings` 必须为空数组。
|
|
107
|
+
- `stop=false` 时,`mustFixFindings` 必须至少有一条。
|
|
108
|
+
- 每个 finding 必须包含这些字段且非空:`id`、`priority`、`category`、`file`、`line`、`title`、`description`、`suggestion`
|
|
109
|
+
- `priority` 只能是 `P0` / `P1` / `P2` / `P3`
|
|
110
|
+
- `mustFixFindings` 只允许 `P0` / `P1`
|
|
111
|
+
- `optionalFindings` 只允许 `P2` / `P3`
|
|
112
|
+
|
|
113
|
+
## 重复分组与 decision-log(仅作为你的思考步骤)
|
|
114
|
+
|
|
115
|
+
你仍然需要基于所有 `reviewFile` 内容判断重复问题和 decision-log 匹配,但这些中间结果**不再**单独传给脚本;它们只体现在最终的聚合结果 JSON 里。
|
|
116
|
+
|
|
65
117
|
## 智能匹配(仅在模式 A + decision-log 存在时)
|
|
66
118
|
|
|
67
|
-
如果 decision-log(`./.cache/decision-log-pr<PR_NUMBER>.md`)存在,你需要基于 LLM 判断每个新 finding
|
|
119
|
+
如果 decision-log(`./.cache/decision-log-pr<PR_NUMBER>.md`)存在,你需要基于 LLM 判断每个新 finding 与已决策问题的本质是否相同,并把判断结果体现在最终聚合结果里。
|
|
68
120
|
|
|
69
121
|
**匹配原则**:
|
|
70
122
|
- **Essence 匹配**:对比 `essence` 字段与新 finding 的问题本质。
|
|
@@ -84,23 +136,11 @@ runId: 123-1-a1b2c3d
|
|
|
84
136
|
- 例如:已 rejected P3 but finding 为 P1 → 可升级质疑
|
|
85
137
|
- 例如:已 rejected P2 but finding 为 P0 → 可升级质疑
|
|
86
138
|
- 例如:已 rejected P2 but finding 为 P1 → 不升级(仅差 1 级)
|
|
87
|
-
5.
|
|
88
|
-
|
|
89
|
-
```json
|
|
90
|
-
{"escalationGroups":[["SEC-001"],["MAINT-002","BIZ-005"]]}
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
其中每个组表示「可以作为已 rejected 问题的升级质疑」的 finding ID 集合。若无可升级问题,输出空数组:
|
|
94
|
-
|
|
95
|
-
```json
|
|
96
|
-
{"escalationGroups":[]}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
注意:escalation_groups JSON **不是你的最终输出**,它只用于生成 `--escalation-groups-b64` 传给脚本。
|
|
139
|
+
5. 只把满足条件的升级问题保留到最终 `mustFixFindings` 或 `optionalFindings`;其余继续按 rejected 过滤。
|
|
100
140
|
|
|
101
141
|
## 调用脚本(强制)
|
|
102
142
|
|
|
103
|
-
模式 A(带 reviewFile +
|
|
143
|
+
模式 A(带 reviewFile + 聚合结果):
|
|
104
144
|
|
|
105
145
|
```bash
|
|
106
146
|
python3 "${CODEX_HOME:-$HOME/.codex}/skills/pr-review-loop/scripts/pr_review_aggregate.py" \
|
|
@@ -111,16 +151,12 @@ python3 "${CODEX_HOME:-$HOME/.codex}/skills/pr-review-loop/scripts/pr_review_agg
|
|
|
111
151
|
--review-file <REVIEW_FILE_1> \
|
|
112
152
|
--review-file <REVIEW_FILE_2> \
|
|
113
153
|
--review-file <REVIEW_FILE_3> \
|
|
114
|
-
--
|
|
115
|
-
--decision-log-file ./.cache/decision-log-pr<PR_NUMBER>.md \
|
|
116
|
-
--escalation-groups-b64 <BASE64_JSON>
|
|
154
|
+
--aggregate-result-b64 <BASE64_JSON>
|
|
117
155
|
```
|
|
118
156
|
|
|
119
157
|
**参数说明**:
|
|
120
158
|
|
|
121
|
-
- `--
|
|
122
|
-
- `--decision-log-file`:decision-log 文件路径(可选;若不存在则跳过智能匹配逻辑)
|
|
123
|
-
- `--escalation-groups-b64`:base64 编码的 escalation groups JSON,格式如上,例如 `eyJlc2NhbGF0aW9uR3JvdXBzIjpbWyJDRFgtMDAxIl1dfQ==`
|
|
159
|
+
- `--aggregate-result-b64`:base64 编码的聚合结果 JSON
|
|
124
160
|
|
|
125
161
|
模式 B(带 fixReportFile):
|
|
126
162
|
|
|
@@ -146,7 +182,7 @@ python3 "${CODEX_HOME:-$HOME/.codex}/skills/pr-review-loop/scripts/pr_review_agg
|
|
|
146
182
|
|
|
147
183
|
## fixFile 结构(补充说明)
|
|
148
184
|
|
|
149
|
-
脚本在模式 A
|
|
185
|
+
脚本在模式 A 下根据你提供的聚合结果生成 fixFile,分为两段:
|
|
150
186
|
|
|
151
187
|
- `## IssuesToFix`:只包含 P0/P1(必须修)
|
|
152
188
|
- `## OptionalIssues`:包含 P2/P3(由 fixer 自主决定是否修复,或拒绝并说明原因)
|
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
# Deterministic PR review aggregation (script owns all rules).
|
|
3
3
|
#
|
|
4
4
|
# Workflow:
|
|
5
|
-
# - Mode A: read contextFile + reviewFile(s) from project cache: ./.cache/,
|
|
5
|
+
# - Mode A: read contextFile + reviewFile(s) from project cache: ./.cache/, consume an LLM-produced aggregate result,
|
|
6
6
|
# post a single PR comment, and generate a fixFile for fixer.
|
|
7
7
|
# - Mode B: read fixReportFile from cache and post it as a PR comment.
|
|
8
8
|
#
|
|
9
9
|
# Input rules:
|
|
10
10
|
# - Callers should pass repo-relative paths (e.g. ./.cache/foo.md). For backward-compat, basenames are also accepted.
|
|
11
|
-
# -
|
|
12
|
-
# - Prefer: --
|
|
13
|
-
# - Also supported: --
|
|
14
|
-
# -
|
|
11
|
+
# - Aggregate result comes from LLM and is passed as an argument (NOT written to disk).
|
|
12
|
+
# - Prefer: --aggregate-result-b64 <base64(json)>
|
|
13
|
+
# - Also supported: --aggregate-result-json '<json>'
|
|
14
|
+
# - Missing/invalid aggregate result => fail closed.
|
|
15
|
+
# - Duplicate groups / escalation groups may still be passed for backward-compatible tooling,
|
|
16
|
+
# but they are no longer used by this script to decide findings or stop.
|
|
15
17
|
#
|
|
16
18
|
# Output rules:
|
|
17
19
|
# - Stdout must print exactly ONE JSON object and nothing else.
|
|
@@ -459,6 +461,101 @@ def _parse_review_findings(md_text):
|
|
|
459
461
|
return normalized
|
|
460
462
|
|
|
461
463
|
|
|
464
|
+
def _normalize_aggregate_finding(it):
|
|
465
|
+
if not isinstance(it, dict):
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
required_fields = [
|
|
469
|
+
"id",
|
|
470
|
+
"priority",
|
|
471
|
+
"category",
|
|
472
|
+
"file",
|
|
473
|
+
"line",
|
|
474
|
+
"title",
|
|
475
|
+
"description",
|
|
476
|
+
"suggestion",
|
|
477
|
+
]
|
|
478
|
+
for field in required_fields:
|
|
479
|
+
val = it.get(field)
|
|
480
|
+
if not isinstance(val, str) or not val.strip():
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
priority = it.get("priority", "").strip().upper()
|
|
484
|
+
if priority not in {"P0", "P1", "P2", "P3"}:
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
"id": it["id"].strip(),
|
|
489
|
+
"priority": priority,
|
|
490
|
+
"category": it["category"].strip(),
|
|
491
|
+
"file": it["file"].strip(),
|
|
492
|
+
"line": it["line"].strip(),
|
|
493
|
+
"title": it["title"].strip(),
|
|
494
|
+
"description": it["description"].strip(),
|
|
495
|
+
"suggestion": it["suggestion"].strip(),
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _normalize_aggregate_findings(items):
|
|
500
|
+
if not isinstance(items, list):
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
out = []
|
|
504
|
+
for it in items:
|
|
505
|
+
normalized = _normalize_aggregate_finding(it)
|
|
506
|
+
if not normalized:
|
|
507
|
+
return None
|
|
508
|
+
out.append(normalized)
|
|
509
|
+
return out
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _parse_aggregate_result_json(s):
|
|
513
|
+
if not s:
|
|
514
|
+
return None
|
|
515
|
+
try:
|
|
516
|
+
data = json.loads(s)
|
|
517
|
+
except Exception:
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
if not isinstance(data, dict):
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
stop = data.get("stop")
|
|
524
|
+
if not isinstance(stop, bool):
|
|
525
|
+
return None
|
|
526
|
+
|
|
527
|
+
must_fix = _normalize_aggregate_findings(data.get("mustFixFindings"))
|
|
528
|
+
optional = _normalize_aggregate_findings(data.get("optionalFindings"))
|
|
529
|
+
if must_fix is None or optional is None:
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
if any(_priority_rank(f.get("priority")) > 1 for f in must_fix):
|
|
533
|
+
return None
|
|
534
|
+
if any(_priority_rank(f.get("priority")) < 2 for f in optional):
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
if stop and must_fix:
|
|
538
|
+
return None
|
|
539
|
+
if (not stop) and (not must_fix):
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
"stop": stop,
|
|
544
|
+
"mustFixFindings": must_fix,
|
|
545
|
+
"optionalFindings": optional,
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _parse_aggregate_result_b64(s):
|
|
550
|
+
if not s:
|
|
551
|
+
return None
|
|
552
|
+
try:
|
|
553
|
+
raw = base64.b64decode(s.encode("ascii"), validate=True)
|
|
554
|
+
return _parse_aggregate_result_json(raw.decode("utf-8", errors="replace"))
|
|
555
|
+
except Exception:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
|
|
462
559
|
def _merge_duplicates(findings, duplicate_groups):
|
|
463
560
|
by_id = {f["id"]: dict(f) for f in findings}
|
|
464
561
|
merged_map = {}
|
|
@@ -685,6 +782,8 @@ def main(argv):
|
|
|
685
782
|
parser.add_argument("--review-file", action="append", default=[])
|
|
686
783
|
parser.add_argument("--fix-report-file")
|
|
687
784
|
parser.add_argument("--final-report")
|
|
785
|
+
parser.add_argument("--aggregate-result-json")
|
|
786
|
+
parser.add_argument("--aggregate-result-b64")
|
|
688
787
|
parser.add_argument("--duplicate-groups-json")
|
|
689
788
|
parser.add_argument("--duplicate-groups-b64")
|
|
690
789
|
parser.add_argument("--decision-log-file")
|
|
@@ -759,36 +858,22 @@ def main(argv):
|
|
|
759
858
|
return 1
|
|
760
859
|
|
|
761
860
|
raw_reviews = []
|
|
762
|
-
all_findings = []
|
|
763
861
|
for rf in review_files:
|
|
764
862
|
md = _read_cache_text(rf)
|
|
765
863
|
raw_reviews.append((rf, md))
|
|
766
|
-
|
|
864
|
+
aggregate_result = _parse_aggregate_result_json(args.aggregate_result_json or "")
|
|
865
|
+
if not aggregate_result:
|
|
866
|
+
aggregate_result = _parse_aggregate_result_b64(args.aggregate_result_b64 or "")
|
|
867
|
+
if not aggregate_result:
|
|
868
|
+
_json_out({"error": "INVALID_AGGREGATE_RESULT"})
|
|
869
|
+
return 1
|
|
767
870
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
decision_log_file = (args.decision_log_file or "").strip() or None
|
|
774
|
-
prior_decisions = []
|
|
775
|
-
if decision_log_file:
|
|
776
|
-
try:
|
|
777
|
-
decision_log_md = _read_cache_text(decision_log_file)
|
|
778
|
-
prior_decisions = _parse_decision_log(decision_log_md)
|
|
779
|
-
except Exception:
|
|
780
|
-
pass
|
|
781
|
-
|
|
782
|
-
escalation_groups = _parse_escalation_groups_b64(args.escalation_groups_b64 or "")
|
|
783
|
-
|
|
784
|
-
if prior_decisions:
|
|
785
|
-
merged_findings = _filter_by_decision_log(merged_findings, prior_decisions, escalation_groups)
|
|
786
|
-
|
|
871
|
+
must_fix = list(aggregate_result["mustFixFindings"])
|
|
872
|
+
optional = list(aggregate_result["optionalFindings"])
|
|
873
|
+
merged_findings = must_fix + optional
|
|
874
|
+
merged_map = {}
|
|
787
875
|
counts = _counts(merged_findings)
|
|
788
|
-
|
|
789
|
-
must_fix = [f for f in merged_findings if _priority_rank(f.get("priority")) <= 1]
|
|
790
|
-
optional = [f for f in merged_findings if _priority_rank(f.get("priority")) >= 2]
|
|
791
|
-
stop = len(must_fix) == 0
|
|
876
|
+
stop = bool(aggregate_result["stop"])
|
|
792
877
|
|
|
793
878
|
body = _render_mode_a_comment(pr_number, round_num, run_id, counts, must_fix, merged_map, raw_reviews)
|
|
794
879
|
body_basename = f"review-aggregate-comment-pr{pr_number}-r{round_num}-{run_id}.md"
|
|
@@ -48,6 +48,10 @@ _parse_review_findings = cast(
|
|
|
48
48
|
Callable[[str], list[dict[str, object]]],
|
|
49
49
|
getattr(_pr_review_aggregate, "_parse_review_findings"),
|
|
50
50
|
)
|
|
51
|
+
_parse_aggregate_result_json = cast(
|
|
52
|
+
Callable[[str], dict[str, object] | None],
|
|
53
|
+
getattr(_pr_review_aggregate, "_parse_aggregate_result_json"),
|
|
54
|
+
)
|
|
51
55
|
_check_existing_comment = cast(
|
|
52
56
|
Callable[[int, str, int, str], bool],
|
|
53
57
|
getattr(_pr_review_aggregate, "_check_existing_comment"),
|
|
@@ -562,6 +566,127 @@ def test_parse_review_findings_keeps_legacy_list_format() -> None:
|
|
|
562
566
|
assert result[0]["priority"] == "P1"
|
|
563
567
|
|
|
564
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
|
+
|
|
565
690
|
# ============================================================
|
|
566
691
|
# Test: Integration - Full Workflow
|
|
567
692
|
# ============================================================
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Unit tests for validate_reviewer_prompts.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import importlib.util
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable, cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _load_module():
|
|
12
|
+
module_path = Path(__file__).with_name("validate_reviewer_prompts.py")
|
|
13
|
+
spec = importlib.util.spec_from_file_location("validate_reviewer_prompts", module_path)
|
|
14
|
+
if spec is None or spec.loader is None:
|
|
15
|
+
raise RuntimeError(f"Failed to load module spec: {module_path}")
|
|
16
|
+
module = importlib.util.module_from_spec(spec)
|
|
17
|
+
spec.loader.exec_module(module)
|
|
18
|
+
return module
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_module = _load_module()
|
|
22
|
+
_validate_prompt_text = cast(Callable[[str], list[str]], getattr(_module, "_validate_prompt_text"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_validate_prompt_text_accepts_complete_contract() -> None:
|
|
26
|
+
text = """
|
|
27
|
+
# PR Reviewer (Security)
|
|
28
|
+
|
|
29
|
+
## 角色码(强制)
|
|
30
|
+
|
|
31
|
+
- `ROLE_CODE = SEC`
|
|
32
|
+
- `reviewFile`: `./.cache/review-SEC-pr<PR_NUMBER>-r<ROUND>-<RUN_ID>.md`
|
|
33
|
+
- findings id 前缀:`SEC-`
|
|
34
|
+
|
|
35
|
+
## 输出格式(强制)
|
|
36
|
+
|
|
37
|
+
- 必须写入 `reviewFile`,禁止只在 stdout 输出审查结果。
|
|
38
|
+
- 若无问题,文件内容必须严格为以下模板:
|
|
39
|
+
|
|
40
|
+
```md
|
|
41
|
+
# Review (SEC)
|
|
42
|
+
PR: 123
|
|
43
|
+
Round: 1
|
|
44
|
+
RunId: 123-1-abcdef0
|
|
45
|
+
|
|
46
|
+
## Findings
|
|
47
|
+
None
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- 若有问题,每个 finding 必须使用如下块格式,字段名必须逐字一致:
|
|
51
|
+
|
|
52
|
+
```md
|
|
53
|
+
# Review (SEC)
|
|
54
|
+
PR: 123
|
|
55
|
+
Round: 1
|
|
56
|
+
RunId: 123-1-abcdef0
|
|
57
|
+
|
|
58
|
+
## Findings
|
|
59
|
+
id: SEC-001
|
|
60
|
+
priority: P1
|
|
61
|
+
category: bug
|
|
62
|
+
file: apps/api/src/service.ts
|
|
63
|
+
line: 10
|
|
64
|
+
title: 标题
|
|
65
|
+
description: 描述
|
|
66
|
+
suggestion: 建议
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `priority` 只能是 `P0`、`P1`、`P2`、`P3`。
|
|
70
|
+
- `category` 只允许使用英文小写单词或短语。
|
|
71
|
+
- `line` 必须是单个行号数字;未知时写 `null`。
|
|
72
|
+
- 多个 finding 之间必须空一行;禁止额外嵌套列表、表格、代码块或总结性 prose。
|
|
73
|
+
- 输出前必须自检:字段齐全、非空、前缀与 `ROLE_CODE` 一致。
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
assert _validate_prompt_text(text) == []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_validate_prompt_text_reports_missing_contract_sections() -> None:
|
|
80
|
+
text = """
|
|
81
|
+
# PR Reviewer (Style)
|
|
82
|
+
|
|
83
|
+
## 角色码(强制)
|
|
84
|
+
|
|
85
|
+
- `ROLE_CODE = STY`
|
|
86
|
+
- `reviewFile`: `./.cache/review-STY-pr<PR_NUMBER>-r<ROUND>-<RUN_ID>.md`
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
errors = _validate_prompt_text(text)
|
|
90
|
+
assert any("输出格式(强制)" in err for err in errors)
|
|
91
|
+
assert any("None" in err for err in errors)
|
|
92
|
+
assert any("priority" in err for err in errors)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Validate reviewer prompt files contain the required output contract.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
REQUIRED_SNIPPETS = [
|
|
14
|
+
"## 输出格式(强制)",
|
|
15
|
+
"## Findings",
|
|
16
|
+
"None",
|
|
17
|
+
"id:",
|
|
18
|
+
"priority:",
|
|
19
|
+
"category:",
|
|
20
|
+
"file:",
|
|
21
|
+
"line:",
|
|
22
|
+
"title:",
|
|
23
|
+
"description:",
|
|
24
|
+
"suggestion:",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_prompt_text(text: str) -> list[str]:
|
|
29
|
+
errors: list[str] = []
|
|
30
|
+
for snippet in REQUIRED_SNIPPETS:
|
|
31
|
+
if snippet not in text:
|
|
32
|
+
errors.append(f"缺少必需片段: {snippet}")
|
|
33
|
+
|
|
34
|
+
role_match = re.search(r"ROLE_CODE\s*=\s*([A-Z0-9]+)", text)
|
|
35
|
+
if not role_match:
|
|
36
|
+
errors.append("缺少 ROLE_CODE = <CODE>")
|
|
37
|
+
return errors
|
|
38
|
+
|
|
39
|
+
role_code = role_match.group(1)
|
|
40
|
+
if f"id: {role_code}-001" not in text:
|
|
41
|
+
errors.append(f"缺少与 ROLE_CODE 匹配的 finding id 示例: id: {role_code}-001")
|
|
42
|
+
|
|
43
|
+
review_file_pattern = rf"review-{role_code}-pr<PR_NUMBER>-r<ROUND>-<RUN_ID>\.md"
|
|
44
|
+
if not re.search(review_file_pattern, text):
|
|
45
|
+
errors.append(f"reviewFile 模板未与 ROLE_CODE 对齐: {role_code}")
|
|
46
|
+
|
|
47
|
+
return errors
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate_file(path: Path) -> list[str]:
|
|
51
|
+
try:
|
|
52
|
+
text = path.read_text(encoding="utf-8")
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
return [f"文件不可读: {exc}"]
|
|
55
|
+
return _validate_prompt_text(text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv: list[str]) -> int:
|
|
59
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
60
|
+
parser.add_argument("paths", nargs="+")
|
|
61
|
+
args = parser.parse_args(argv)
|
|
62
|
+
|
|
63
|
+
invalid: dict[str, list[str]] = {}
|
|
64
|
+
checked: list[str] = []
|
|
65
|
+
for raw in args.paths:
|
|
66
|
+
path = Path(raw)
|
|
67
|
+
checked.append(str(path))
|
|
68
|
+
errors = _validate_file(path)
|
|
69
|
+
if errors:
|
|
70
|
+
invalid[str(path)] = errors
|
|
71
|
+
|
|
72
|
+
if invalid:
|
|
73
|
+
sys.stdout.write(
|
|
74
|
+
json.dumps(
|
|
75
|
+
{"ok": False, "checked": checked, "invalid": invalid},
|
|
76
|
+
ensure_ascii=False,
|
|
77
|
+
)
|
|
78
|
+
+ "\n"
|
|
79
|
+
)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
sys.stdout.write(json.dumps({"ok": True, "checked": checked}, ensure_ascii=False) + "\n")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -38,6 +38,8 @@ class PM2StackManager {
|
|
|
38
38
|
? options.services.map(item => String(item).trim()).filter(Boolean)
|
|
39
39
|
: [...DEFAULT_SERVICES]
|
|
40
40
|
|
|
41
|
+
this.urls = options.urls && typeof options.urls === 'object' ? options.urls : {}
|
|
42
|
+
|
|
41
43
|
const incomingPreflight = options.preflight && typeof options.preflight === 'object'
|
|
42
44
|
? options.preflight
|
|
43
45
|
: {}
|
|
@@ -211,6 +213,19 @@ class PM2StackManager {
|
|
|
211
213
|
logger.warn(stderr)
|
|
212
214
|
}
|
|
213
215
|
logger.success('服务启动成功')
|
|
216
|
+
this.printServiceUrls()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
printServiceUrls() {
|
|
220
|
+
const entries = Object.entries(this.urls)
|
|
221
|
+
if (entries.length === 0) return
|
|
222
|
+
|
|
223
|
+
console.log('')
|
|
224
|
+
logger.info('服务访问链接:')
|
|
225
|
+
for (const [service, url] of entries) {
|
|
226
|
+
console.log(` ${service.padEnd(12)} → ${url}`)
|
|
227
|
+
}
|
|
228
|
+
console.log('')
|
|
214
229
|
}
|
|
215
230
|
|
|
216
231
|
async showStatus() {
|