cowork-flow 0.0.26 → 0.0.28
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/README.md +16 -8
- package/package.json +1 -1
- package/src/lib/copy-template.js +2 -2
- package/template/.agents/skills/before-dev/SKILL.md +1 -0
- package/template/.agents/skills/check/SKILL.md +1 -0
- package/template/.agents/skills/party-mode-v2/SKILL.md +75 -0
- package/template/.agents/skills/tdd/SKILL.md +61 -0
- package/template/.agents/skills/writing-plans/SKILL.md +1 -0
- package/template/.claude/agents/cowork-implement.md +153 -0
- package/template/.claude/hooks/inject-workflow-state.py +17 -8
- package/template/.claude/skills/before-dev/SKILL.md +1 -0
- package/template/.claude/skills/check/SKILL.md +1 -0
- package/template/.claude/skills/party-mode-v2/SKILL.md +75 -0
- package/template/.claude/skills/tdd/SKILL.md +61 -0
- package/template/.claude/skills/writing-plans/SKILL.md +1 -0
- package/template/.codex/agents/cowork-check.toml +2 -0
- package/template/.codex/agents/cowork-implement.toml +2 -0
- package/template/.codex/hooks/inject-workflow-state.py +17 -8
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/config.yaml +16 -0
- package/template/.cowork-flow/scripts/common/archive_utils.py +1 -1
- package/template/.cowork-flow/scripts/common/coding_standards.py +209 -0
- package/template/.cowork-flow/scripts/common/config.py +93 -0
- package/template/.cowork-flow/scripts/common/gates.py +66 -0
- package/template/.cowork-flow/scripts/common/git_snapshot.py +83 -0
- package/template/.cowork-flow/scripts/common/state_machine.py +57 -0
- package/template/.cowork-flow/scripts/common/tdd_evidence.py +301 -0
- package/template/.cowork-flow/scripts/common/test_intent.py +265 -0
- package/template/.cowork-flow/scripts/common/validate_coding_standards.py +181 -0
- package/template/.cowork-flow/scripts/common/validate_implementation.py +243 -0
- package/template/.cowork-flow/scripts/common/validate_rules.py +465 -0
- package/template/.cowork-flow/scripts/doctor.py +18 -36
- package/template/.cowork-flow/scripts/party_mode_v2.py +1162 -0
- package/template/.cowork-flow/scripts/run.py +3 -0
- package/template/.cowork-flow/scripts/task.py +190 -0
- package/template/.cowork-flow/spec/contracts/index.md +12 -0
- package/template/.cowork-flow/spec/contracts/party-mode-v2-board.md +68 -0
- package/template/.cowork-flow/spec/{subagent-dispatch.md → contracts/subagent-dispatch.md} +2 -0
- package/template/.cowork-flow/spec/{workflow-state-templates.md → contracts/workflow-state-templates.md} +1 -1
- package/template/.cowork-flow/spec/index.md +15 -0
- package/template/.cowork-flow/spec/{registry.json → runtime/contract-registry.json} +17 -4
- package/template/.cowork-flow/spec/runtime/index.md +10 -0
- package/template/.cowork-flow/spec/runtime/rules.json +173 -0
- package/template/.cowork-flow/spec/schemas/index.md +10 -0
- package/template/.cowork-flow/spec/schemas/party-mode-v2-actions.schema.json +90 -0
- package/template/.cowork-flow/spec/schemas/rules.schema.json +80 -0
- package/template/.cowork-flow/workflow.md +13 -7
- package/template/.opencode/agents/cowork-implement.md +153 -0
- package/template/.opencode/commands/party-mode-v2.md +26 -0
- package/template/.opencode/plugins/cowork-flow.js +18 -7
- package/template/AGENTS.md +2 -6
- package/template/.cowork-flow/scripts/common/entry_classifier.py +0 -122
- /package/template/.cowork-flow/spec/{capabilities.md → contracts/capabilities.md} +0 -0
- /package/template/.cowork-flow/spec/{entry-contract.md → contracts/entry-contract.md} +0 -0
- /package/template/.cowork-flow/spec/{adapter.schema.json → schemas/adapter.schema.json} +0 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""TDD evidence validation for task review gates."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
EVIDENCE_FILE = "tdd.jsonl"
|
|
11
|
+
|
|
12
|
+
REQUIRED_EVIDENCE_FIELDS = (
|
|
13
|
+
"acceptanceId",
|
|
14
|
+
"testFile",
|
|
15
|
+
"testName",
|
|
16
|
+
"redCommand",
|
|
17
|
+
"redExitCode",
|
|
18
|
+
"redOutputExcerpt",
|
|
19
|
+
"failureReason",
|
|
20
|
+
"whyThisTestMatters",
|
|
21
|
+
"greenCommand",
|
|
22
|
+
"greenExitCode",
|
|
23
|
+
"broaderVerification",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
REQUIRED_EXEMPTION_FIELDS = (
|
|
27
|
+
"acceptanceId",
|
|
28
|
+
"exemptionType",
|
|
29
|
+
"reason",
|
|
30
|
+
"verificationCommand",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
TDD_REQUIRED_MARKERS = (
|
|
34
|
+
"behavior",
|
|
35
|
+
"bug",
|
|
36
|
+
"cli",
|
|
37
|
+
"runtime",
|
|
38
|
+
"gate",
|
|
39
|
+
"protocol",
|
|
40
|
+
"state",
|
|
41
|
+
"workflow",
|
|
42
|
+
"行为",
|
|
43
|
+
"行为变更",
|
|
44
|
+
"修复",
|
|
45
|
+
"状态",
|
|
46
|
+
"状态机",
|
|
47
|
+
"协议",
|
|
48
|
+
"门禁",
|
|
49
|
+
"阻断",
|
|
50
|
+
"数据格式",
|
|
51
|
+
"错误处理",
|
|
52
|
+
"实现",
|
|
53
|
+
"新增",
|
|
54
|
+
"修改",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
NON_BEHAVIOR_MARKERS = (
|
|
58
|
+
"docs-only",
|
|
59
|
+
"documentation_only",
|
|
60
|
+
"documentation only",
|
|
61
|
+
"纯文档",
|
|
62
|
+
"仅文档",
|
|
63
|
+
"注释",
|
|
64
|
+
"格式化",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
SETUP_FAILURE_MARKERS = (
|
|
68
|
+
"syntax",
|
|
69
|
+
"import",
|
|
70
|
+
"environment",
|
|
71
|
+
"setup",
|
|
72
|
+
"fixture",
|
|
73
|
+
"语法",
|
|
74
|
+
"导入",
|
|
75
|
+
"环境",
|
|
76
|
+
"测试设置",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate_tdd_evidence(task_dir: Path) -> list[dict]:
|
|
81
|
+
"""Return TDD evidence violations for a task directory."""
|
|
82
|
+
task_dir = Path(task_dir)
|
|
83
|
+
prd_text = _read_text(task_dir / "prd.md")
|
|
84
|
+
evidence_path = task_dir / EVIDENCE_FILE
|
|
85
|
+
|
|
86
|
+
if not evidence_path.is_file():
|
|
87
|
+
if _task_requires_tdd(prd_text):
|
|
88
|
+
return [
|
|
89
|
+
_violation(
|
|
90
|
+
"TDD-RED-001",
|
|
91
|
+
"TDD evidence file is missing for a behavior-change task",
|
|
92
|
+
evidence_path,
|
|
93
|
+
"Create tdd.jsonl with red/green evidence or a documented exemption.",
|
|
94
|
+
)
|
|
95
|
+
]
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
entries, parse_violations = _read_jsonl(evidence_path)
|
|
99
|
+
if parse_violations:
|
|
100
|
+
return parse_violations
|
|
101
|
+
|
|
102
|
+
if not entries:
|
|
103
|
+
return [
|
|
104
|
+
_violation(
|
|
105
|
+
"TDD-RED-002",
|
|
106
|
+
"TDD evidence file is empty",
|
|
107
|
+
evidence_path,
|
|
108
|
+
"Add at least one evidence or exemption record.",
|
|
109
|
+
)
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
acceptance_ids = _acceptance_ids(prd_text)
|
|
113
|
+
violations: list[dict] = []
|
|
114
|
+
for index, entry in enumerate(entries, start=1):
|
|
115
|
+
if entry.get("type") == "exemption":
|
|
116
|
+
violations.extend(_validate_exemption(entry, evidence_path, index, acceptance_ids))
|
|
117
|
+
else:
|
|
118
|
+
violations.extend(_validate_evidence(entry, evidence_path, index, acceptance_ids))
|
|
119
|
+
|
|
120
|
+
return violations
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _task_requires_tdd(prd_text: str) -> bool:
|
|
124
|
+
lower = prd_text.lower()
|
|
125
|
+
if not lower:
|
|
126
|
+
return False
|
|
127
|
+
if any(marker.lower() in lower for marker in NON_BEHAVIOR_MARKERS):
|
|
128
|
+
return False
|
|
129
|
+
return any(marker.lower() in lower for marker in TDD_REQUIRED_MARKERS)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _read_text(path: Path) -> str:
|
|
133
|
+
try:
|
|
134
|
+
return path.read_text(encoding="utf-8")
|
|
135
|
+
except (OSError, UnicodeDecodeError):
|
|
136
|
+
return ""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_jsonl(path: Path) -> tuple[list[dict], list[dict]]:
|
|
140
|
+
entries: list[dict] = []
|
|
141
|
+
violations: list[dict] = []
|
|
142
|
+
try:
|
|
143
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
144
|
+
except (OSError, UnicodeDecodeError):
|
|
145
|
+
return entries, [
|
|
146
|
+
_violation(
|
|
147
|
+
"TDD-READ-001",
|
|
148
|
+
"Failed to read TDD evidence as UTF-8",
|
|
149
|
+
path,
|
|
150
|
+
"Rewrite tdd.jsonl with UTF-8 encoding.",
|
|
151
|
+
)
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
for line_number, line in enumerate(lines, start=1):
|
|
155
|
+
if not line.strip():
|
|
156
|
+
continue
|
|
157
|
+
try:
|
|
158
|
+
data = json.loads(line)
|
|
159
|
+
except json.JSONDecodeError:
|
|
160
|
+
violations.append(
|
|
161
|
+
_violation(
|
|
162
|
+
"TDD-FORMAT-001",
|
|
163
|
+
f"Invalid JSON in tdd.jsonl line {line_number}",
|
|
164
|
+
path,
|
|
165
|
+
"Write one valid JSON object per line.",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
continue
|
|
169
|
+
if not isinstance(data, dict):
|
|
170
|
+
violations.append(
|
|
171
|
+
_violation(
|
|
172
|
+
"TDD-FORMAT-002",
|
|
173
|
+
f"tdd.jsonl line {line_number} must be a JSON object",
|
|
174
|
+
path,
|
|
175
|
+
"Replace non-object entries with structured evidence records.",
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
continue
|
|
179
|
+
entries.append(data)
|
|
180
|
+
|
|
181
|
+
return entries, violations
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _acceptance_ids(prd_text: str) -> set[str]:
|
|
185
|
+
return set(re.findall(r"\bAC-\d{3}\b", prd_text))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _validate_evidence(
|
|
189
|
+
entry: dict,
|
|
190
|
+
evidence_path: Path,
|
|
191
|
+
index: int,
|
|
192
|
+
acceptance_ids: set[str],
|
|
193
|
+
) -> list[dict]:
|
|
194
|
+
violations: list[dict] = []
|
|
195
|
+
missing = [field for field in REQUIRED_EVIDENCE_FIELDS if not _has_value(entry, field)]
|
|
196
|
+
if missing:
|
|
197
|
+
violations.append(
|
|
198
|
+
_violation(
|
|
199
|
+
"TDD-FIELD-001",
|
|
200
|
+
f"TDD evidence record {index} is missing required fields: {', '.join(missing)}",
|
|
201
|
+
evidence_path,
|
|
202
|
+
"Fill all red/green evidence fields before review.",
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
acceptance_id = str(entry.get("acceptanceId", "")).strip()
|
|
207
|
+
if acceptance_ids and acceptance_id not in acceptance_ids:
|
|
208
|
+
violations.append(
|
|
209
|
+
_violation(
|
|
210
|
+
"TDD-AC-001",
|
|
211
|
+
f"TDD evidence record {index} references unknown acceptanceId: {acceptance_id}",
|
|
212
|
+
evidence_path,
|
|
213
|
+
"Map evidence to a PRD acceptance ID such as AC-001.",
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if entry.get("redExitCode") == 0:
|
|
218
|
+
violations.append(
|
|
219
|
+
_violation(
|
|
220
|
+
"TDD-RED-003",
|
|
221
|
+
f"TDD evidence record {index} redExitCode must be non-zero",
|
|
222
|
+
evidence_path,
|
|
223
|
+
"Capture the failing test result before implementation.",
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if entry.get("greenExitCode") != 0:
|
|
228
|
+
violations.append(
|
|
229
|
+
_violation(
|
|
230
|
+
"TDD-GREEN-001",
|
|
231
|
+
f"TDD evidence record {index} greenExitCode must be 0",
|
|
232
|
+
evidence_path,
|
|
233
|
+
"Run the same behavior test after implementation and record success.",
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
failure_reason = str(entry.get("failureReason", "")).lower()
|
|
238
|
+
if any(marker in failure_reason for marker in SETUP_FAILURE_MARKERS):
|
|
239
|
+
violations.append(
|
|
240
|
+
_violation(
|
|
241
|
+
"TDD-RED-004",
|
|
242
|
+
f"TDD evidence record {index} red failure is not target behavior",
|
|
243
|
+
evidence_path,
|
|
244
|
+
"Red failure must come from the missing/incorrect target behavior, not setup noise.",
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return violations
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _validate_exemption(
|
|
252
|
+
entry: dict,
|
|
253
|
+
evidence_path: Path,
|
|
254
|
+
index: int,
|
|
255
|
+
acceptance_ids: set[str],
|
|
256
|
+
) -> list[dict]:
|
|
257
|
+
violations: list[dict] = []
|
|
258
|
+
missing = [field for field in REQUIRED_EXEMPTION_FIELDS if not _has_value(entry, field)]
|
|
259
|
+
if missing:
|
|
260
|
+
violations.append(
|
|
261
|
+
_violation(
|
|
262
|
+
"TDD-EXEMPT-001",
|
|
263
|
+
f"TDD exemption record {index} is missing required fields: {', '.join(missing)}",
|
|
264
|
+
evidence_path,
|
|
265
|
+
"Record exemptionType, reason, acceptanceId, and verificationCommand.",
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
acceptance_id = str(entry.get("acceptanceId", "")).strip()
|
|
270
|
+
if acceptance_ids and acceptance_id not in acceptance_ids:
|
|
271
|
+
violations.append(
|
|
272
|
+
_violation(
|
|
273
|
+
"TDD-EXEMPT-002",
|
|
274
|
+
f"TDD exemption record {index} references unknown acceptanceId: {acceptance_id}",
|
|
275
|
+
evidence_path,
|
|
276
|
+
"Map the exemption to a PRD acceptance ID such as AC-001.",
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return violations
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _has_value(entry: dict, field: str) -> bool:
|
|
284
|
+
value = entry.get(field)
|
|
285
|
+
if value is None:
|
|
286
|
+
return False
|
|
287
|
+
if isinstance(value, str):
|
|
288
|
+
return bool(value.strip())
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _violation(rule_id: str, message: str, file_path: Path, fix_hint: str) -> dict:
|
|
293
|
+
return {
|
|
294
|
+
"rule_id": rule_id,
|
|
295
|
+
"type": "tdd_evidence",
|
|
296
|
+
"severity": "block",
|
|
297
|
+
"passed": False,
|
|
298
|
+
"message": message,
|
|
299
|
+
"file": str(file_path),
|
|
300
|
+
"fix_hint": fix_hint,
|
|
301
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Test intent validation for cowork-flow review gates."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import tokenize
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .tdd_evidence import EVIDENCE_FILE
|
|
13
|
+
|
|
14
|
+
REPORT_FIELD = "test_intent_review"
|
|
15
|
+
|
|
16
|
+
STRONG_ASSERT_MARKERS = (
|
|
17
|
+
"assertEqual",
|
|
18
|
+
"assertNotEqual",
|
|
19
|
+
"assertGreater",
|
|
20
|
+
"assertGreaterEqual",
|
|
21
|
+
"assertLess",
|
|
22
|
+
"assertLessEqual",
|
|
23
|
+
"assertRaises",
|
|
24
|
+
"assertRegex",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
WEAK_ASSERT_MARKERS = (
|
|
28
|
+
"assertIsNotNone",
|
|
29
|
+
"assertIsInstance",
|
|
30
|
+
"assertIn(",
|
|
31
|
+
"assertNotIn(",
|
|
32
|
+
"assertTrue(",
|
|
33
|
+
"assertFalse(",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
BLOCK_MARKERS = (
|
|
37
|
+
"assert True",
|
|
38
|
+
"assertTrue(True)",
|
|
39
|
+
"assertFalse(False)",
|
|
40
|
+
"hasattr(",
|
|
41
|
+
"getattr(",
|
|
42
|
+
"callable(",
|
|
43
|
+
"assert_called",
|
|
44
|
+
"call_count",
|
|
45
|
+
"assert_not_called",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_test_intent(repo_root: Path, task_dir: Path) -> list[dict]:
|
|
50
|
+
"""Return test intent warnings or blocks for the task's TDD evidence."""
|
|
51
|
+
evidence_path = Path(task_dir) / EVIDENCE_FILE
|
|
52
|
+
entries = _read_tdd_entries(evidence_path)
|
|
53
|
+
if not entries:
|
|
54
|
+
return []
|
|
55
|
+
|
|
56
|
+
violations: list[dict] = []
|
|
57
|
+
for index, entry in enumerate(entries, start=1):
|
|
58
|
+
if entry.get("type") == "exemption":
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
test_file = entry.get("testFile")
|
|
62
|
+
test_name = str(entry.get("testName", "")).strip()
|
|
63
|
+
if not isinstance(test_file, str) or not test_file.strip():
|
|
64
|
+
violations.append(
|
|
65
|
+
_violation(
|
|
66
|
+
"TEST-INTENT-001",
|
|
67
|
+
f"TDD evidence record {index} is missing testFile",
|
|
68
|
+
evidence_path,
|
|
69
|
+
"Point the evidence to a concrete test file.",
|
|
70
|
+
severity="block",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
test_path = Path(repo_root) / test_file
|
|
76
|
+
content = _read_text(test_path)
|
|
77
|
+
if not content:
|
|
78
|
+
violations.append(
|
|
79
|
+
_violation(
|
|
80
|
+
"TEST-INTENT-002",
|
|
81
|
+
f"TDD evidence record {index} references missing test file: {test_file}",
|
|
82
|
+
evidence_path,
|
|
83
|
+
"Point the evidence at an actual test file that exercises behavior.",
|
|
84
|
+
severity="block",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
classification = _classify_test_content(content, test_name)
|
|
90
|
+
if classification == "missing":
|
|
91
|
+
violations.append(
|
|
92
|
+
_violation(
|
|
93
|
+
"TEST-INTENT-005",
|
|
94
|
+
f"TDD evidence record {index} testName does not resolve to a test function: {test_name}",
|
|
95
|
+
test_path,
|
|
96
|
+
"Use test_method, ClassName.test_method, or module.ClassName.test_method for the behavior test.",
|
|
97
|
+
severity="block",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
elif classification == "block":
|
|
101
|
+
violations.append(
|
|
102
|
+
_violation(
|
|
103
|
+
"TEST-INTENT-003",
|
|
104
|
+
f"TDD evidence record {index} points to a shallow test such as assert True, import-only, mock-only, or function-existence checks",
|
|
105
|
+
test_path,
|
|
106
|
+
"Write a test that fails when the target behavior breaks.",
|
|
107
|
+
severity="block",
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
elif classification == "warn":
|
|
111
|
+
violations.append(
|
|
112
|
+
_violation(
|
|
113
|
+
"TEST-INTENT-004",
|
|
114
|
+
f"TDD evidence record {index} looks weak (assertIsNotNone/assertIsInstance/assertIn) and should be reviewed carefully",
|
|
115
|
+
test_path,
|
|
116
|
+
"Prefer tests that assert meaningful behavior or state transitions.",
|
|
117
|
+
severity="warn",
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return violations
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _read_tdd_entries(path: Path) -> list[dict]:
|
|
125
|
+
if not path.is_file():
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
entries: list[dict] = []
|
|
129
|
+
try:
|
|
130
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
131
|
+
except (OSError, UnicodeDecodeError):
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
for line in lines:
|
|
135
|
+
if not line.strip():
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(line)
|
|
139
|
+
except json.JSONDecodeError:
|
|
140
|
+
continue
|
|
141
|
+
if isinstance(data, dict):
|
|
142
|
+
entries.append(data)
|
|
143
|
+
return entries
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _read_text(path: Path) -> str:
|
|
147
|
+
try:
|
|
148
|
+
return path.read_text(encoding="utf-8")
|
|
149
|
+
except (OSError, UnicodeDecodeError):
|
|
150
|
+
return ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _classify_test_content(content: str, test_name: str) -> str:
|
|
154
|
+
target_content = _target_test_content(content, test_name)
|
|
155
|
+
if target_content is None:
|
|
156
|
+
return "missing"
|
|
157
|
+
scan_content = _strip_string_and_comment_literals(target_content)
|
|
158
|
+
lower = scan_content.lower()
|
|
159
|
+
|
|
160
|
+
if any(marker.lower() in lower for marker in BLOCK_MARKERS):
|
|
161
|
+
return "block"
|
|
162
|
+
|
|
163
|
+
if _looks_mock_only(lower):
|
|
164
|
+
return "block"
|
|
165
|
+
|
|
166
|
+
if _looks_import_only(content, lower):
|
|
167
|
+
return "block"
|
|
168
|
+
|
|
169
|
+
if _looks_ambiguous(lower):
|
|
170
|
+
return "warn"
|
|
171
|
+
|
|
172
|
+
return "pass"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _strip_string_and_comment_literals(content: str) -> str:
|
|
176
|
+
try:
|
|
177
|
+
tokens = []
|
|
178
|
+
for token in tokenize.generate_tokens(io.StringIO(content).readline):
|
|
179
|
+
if token.type in (tokenize.STRING, tokenize.COMMENT):
|
|
180
|
+
tokens.append(token._replace(string='""'))
|
|
181
|
+
else:
|
|
182
|
+
tokens.append(token)
|
|
183
|
+
return tokenize.untokenize(tokens)
|
|
184
|
+
except (IndentationError, tokenize.TokenError):
|
|
185
|
+
return content
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _target_test_content(content: str, test_name: str) -> str | None:
|
|
189
|
+
parse_content = content.lstrip("\ufeff")
|
|
190
|
+
try:
|
|
191
|
+
tree = ast.parse(parse_content)
|
|
192
|
+
except SyntaxError:
|
|
193
|
+
return parse_content if test_name and test_name in parse_content else None
|
|
194
|
+
|
|
195
|
+
target_class, target_function = _parse_test_name(test_name)
|
|
196
|
+
if not target_function:
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
for node in ast.walk(tree):
|
|
200
|
+
if target_class and isinstance(node, ast.ClassDef) and node.name == target_class:
|
|
201
|
+
for child in node.body:
|
|
202
|
+
if isinstance(child, ast.FunctionDef) and child.name == target_function:
|
|
203
|
+
return ast.get_source_segment(parse_content, child) or parse_content
|
|
204
|
+
return None
|
|
205
|
+
if not target_class and isinstance(node, ast.FunctionDef) and node.name == target_function:
|
|
206
|
+
return ast.get_source_segment(parse_content, node) or parse_content
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_test_name(test_name: str) -> tuple[str | None, str | None]:
|
|
211
|
+
normalized = test_name.strip()
|
|
212
|
+
if not normalized:
|
|
213
|
+
return None, None
|
|
214
|
+
parts = [part for part in normalized.split(".") if part]
|
|
215
|
+
if not parts:
|
|
216
|
+
return None, None
|
|
217
|
+
target_function = parts[-1]
|
|
218
|
+
target_class = parts[-2] if len(parts) >= 2 and parts[-2][:1].isupper() else None
|
|
219
|
+
return target_class, target_function
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _looks_import_only(content: str, lower: str) -> bool:
|
|
223
|
+
if "assert" in lower:
|
|
224
|
+
return False
|
|
225
|
+
if "expect(" in lower or ".to" in lower:
|
|
226
|
+
return False
|
|
227
|
+
return "def test_" in lower or "class " in lower
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _looks_mock_only(lower: str) -> bool:
|
|
231
|
+
mock_markers = ("assert_called", "call_count", "assert_not_called", "mock(", "Mock(")
|
|
232
|
+
return any(marker in lower for marker in mock_markers) and not any(
|
|
233
|
+
marker in lower for marker in ("assertequal(", "assertraises(", "assertin(", "asserttrue(")
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _looks_ambiguous(lower: str) -> bool:
|
|
238
|
+
weak_only_markers = (
|
|
239
|
+
"assertisnotnone(",
|
|
240
|
+
"assertisinstance(",
|
|
241
|
+
"assertin(",
|
|
242
|
+
"assertnotin(",
|
|
243
|
+
)
|
|
244
|
+
if any(marker in lower for marker in weak_only_markers):
|
|
245
|
+
strong_markers = (
|
|
246
|
+
"assertequal(",
|
|
247
|
+
"assertnotequal(",
|
|
248
|
+
"assertgreater(",
|
|
249
|
+
"assertraises(",
|
|
250
|
+
"assertregex(",
|
|
251
|
+
)
|
|
252
|
+
return not any(marker in lower for marker in strong_markers)
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _violation(rule_id: str, message: str, file_path: Path, fix_hint: str, *, severity: str) -> dict:
|
|
257
|
+
return {
|
|
258
|
+
"rule_id": rule_id,
|
|
259
|
+
"type": "test_intent",
|
|
260
|
+
"severity": severity,
|
|
261
|
+
"passed": False,
|
|
262
|
+
"message": message,
|
|
263
|
+
"file": str(file_path),
|
|
264
|
+
"fix_hint": fix_hint,
|
|
265
|
+
}
|