cowork-flow 0.0.32 → 0.0.33
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/package.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/commands/task.py +28 -1
- package/template/.cowork-flow/scripts/common/core/files.py +8 -0
- package/template/.cowork-flow/scripts/common/gates/anti_rationalization.py +55 -0
- package/template/.cowork-flow/scripts/common/gates/gates.py +5 -0
- package/template/.cowork-flow/scripts/common/gates/spec_validator.py +53 -0
- package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +4 -5
- package/template/.cowork-flow/scripts/common/gates/test_intent.py +1 -5
- package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +170 -131
- package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +32 -0
- package/template/.cowork-flow/scripts/common/gates/validate_rules.py +1 -12
- package/template/.cowork-flow/scripts/common/task/batch_mode.py +145 -0
- package/template/.cowork-flow/scripts/common/task/readiness.py +22 -4
- package/template/.cowork-flow/spec/backend/quality-guidelines.md +2 -0
- package/template/.cowork-flow/spec/contracts/error-output-as-data.md +54 -0
- package/template/.cowork-flow/spec/frontend/quality-guidelines.md +2 -0
- package/template/.cowork-flow/spec/index.md +16 -0
- package/template/.cowork-flow/spec/references/definition-of-done.md +45 -0
- package/template/.cowork-flow/spec/references/index.md +20 -0
- package/template/.cowork-flow/spec/references/orchestration-patterns.md +139 -0
- package/template/.cowork-flow/spec/references/security-checklist.md +51 -0
- package/template/.cowork-flow/spec/references/testing-checklist.md +45 -0
- package/template/.cowork-flow/spec/runtime/contract-registry.json +57 -0
- package/template/.cowork-flow/spec/runtime/rules.json +60 -0
- package/template/.cowork-flow/workflow.md +5 -0
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/runtime/scripts/common/core/files.py +8 -0
- package/template/.zcode/hooks/runtime/scripts/common/gates/anti_rationalization.py +55 -0
- package/template/.zcode/hooks/runtime/scripts/common/gates/gates.py +5 -0
- package/template/.zcode/hooks/runtime/scripts/common/gates/tdd_evidence.py +4 -5
- package/template/.zcode/hooks/runtime/scripts/common/gates/test_intent.py +1 -5
- package/template/.zcode/hooks/runtime/scripts/common/gates/validate_coding_standards.py +294 -133
- package/template/.zcode/hooks/runtime/scripts/common/gates/validate_rules.py +2 -13
- package/template/.zcode/hooks/runtime/scripts/common/task/readiness.py +4 -4
- package/template/AGENTS.md +9 -0
- package/template/skills/batch-mode/SKILL.md +117 -0
- package/template/skills/before-dev/SKILL.md +8 -0
- package/template/skills/break-loop/SKILL.md +53 -0
- package/template/skills/check/SKILL.md +63 -0
- package/template/skills/doubt-review/SKILL.md +118 -0
- package/template/skills/tdd/SKILL.md +13 -0
- package/template/skills/writing-plans/SKILL.md +23 -0
- package/template/.zcode/hooks/runtime/scripts/common/gates/validate_coding_standaries.py +0 -316
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
1
|
+
0.0.33
|
|
@@ -988,6 +988,10 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
988
988
|
)
|
|
989
989
|
return 1
|
|
990
990
|
|
|
991
|
+
if getattr(args, "auto", False):
|
|
992
|
+
from common.task.batch_mode import run_batch_entry
|
|
993
|
+
return run_batch_entry(repo_root, full_path, args)
|
|
994
|
+
|
|
991
995
|
state_blockers = transition_blockers(_load_task_status(full_path), "in_progress")
|
|
992
996
|
if state_blockers:
|
|
993
997
|
_print_transition_blockers(state_blockers)
|
|
@@ -1351,6 +1355,17 @@ def _print_blockers(blockers: list[str]) -> None:
|
|
|
1351
1355
|
print(f" - {blocker}")
|
|
1352
1356
|
|
|
1353
1357
|
|
|
1358
|
+
def _is_git_dirty(repo_root: Path) -> bool:
|
|
1359
|
+
"""Check if the git working tree has uncommitted changes."""
|
|
1360
|
+
try:
|
|
1361
|
+
rc, _, _ = _run_git_command(
|
|
1362
|
+
["status", "--porcelain"], cwd=repo_root
|
|
1363
|
+
)
|
|
1364
|
+
return rc != 0
|
|
1365
|
+
except (OSError, Exception):
|
|
1366
|
+
return False
|
|
1367
|
+
|
|
1368
|
+
|
|
1354
1369
|
def _linked_active_changes_for_task(repo_root: Path, task_dir: Path) -> list[str]:
|
|
1355
1370
|
from commands.change import linked_active_changes_for_task
|
|
1356
1371
|
|
|
@@ -1479,7 +1494,7 @@ def cmd_next(args: argparse.Namespace) -> int:
|
|
|
1479
1494
|
return 0
|
|
1480
1495
|
|
|
1481
1496
|
if status in DONE_STATUSES:
|
|
1482
|
-
print("Next action: finalize,
|
|
1497
|
+
print("Next action: finalize, archive, commit, and record session")
|
|
1483
1498
|
print("Command: git status --short")
|
|
1484
1499
|
linked_changes = _linked_active_changes_for_task(repo_root, task_dir)
|
|
1485
1500
|
print(f"Then: ./.cowork-flow/run task archive {Path(task_path).name}")
|
|
@@ -1546,6 +1561,14 @@ def cmd_archive(args: argparse.Namespace) -> int:
|
|
|
1546
1561
|
if linked_changes and not _linked_changes_ready_for_archive(repo_root, linked_changes):
|
|
1547
1562
|
return 1
|
|
1548
1563
|
|
|
1564
|
+
# R-WF-011: warn if uncommitted changes detected (archive should come before commit)
|
|
1565
|
+
if _is_git_dirty(repo_root):
|
|
1566
|
+
print(colored(
|
|
1567
|
+
"Warning: Uncommitted changes detected. "
|
|
1568
|
+
"Archive the task first, then commit the archived result.",
|
|
1569
|
+
Colors.YELLOW,
|
|
1570
|
+
), file=sys.stderr)
|
|
1571
|
+
|
|
1549
1572
|
# Archive
|
|
1550
1573
|
result = archive_task_complete(task_dir, repo_root)
|
|
1551
1574
|
if "archived_to" not in result:
|
|
@@ -1938,6 +1961,10 @@ def main() -> int:
|
|
|
1938
1961
|
# start
|
|
1939
1962
|
p_start = subparsers.add_parser("start", help="Set active session task")
|
|
1940
1963
|
p_start.add_argument("dir", help="Task directory")
|
|
1964
|
+
p_start.add_argument("--auto", action="store_true",
|
|
1965
|
+
help="Enable batch mode (requires --approved)")
|
|
1966
|
+
p_start.add_argument("--approved", action="store_true",
|
|
1967
|
+
help="User has approved the plan")
|
|
1941
1968
|
|
|
1942
1969
|
# current
|
|
1943
1970
|
subparsers.add_parser("current", help="Show active session task")
|
|
@@ -25,6 +25,14 @@ def read_json_file(path: Path) -> dict | None:
|
|
|
25
25
|
return None
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def read_text_utf8(path: Path) -> str:
|
|
29
|
+
"""Read a text file as UTF-8; return "" if missing or unreadable."""
|
|
30
|
+
try:
|
|
31
|
+
return path.read_text(encoding="utf-8")
|
|
32
|
+
except (OSError, UnicodeDecodeError):
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
28
36
|
def write_json_file(path: Path, data: dict) -> bool:
|
|
29
37
|
"""Write dict to JSON file atomically (temp + os.replace)."""
|
|
30
38
|
try:
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Anti-rationalization pattern detection for TDD exemptions."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
RATIONALIZATION_PATTERNS = (
|
|
7
|
+
"太简单",
|
|
8
|
+
"显而易",
|
|
9
|
+
"没必要",
|
|
10
|
+
"不需要测试",
|
|
11
|
+
"已经有其他测试",
|
|
12
|
+
"肉眼",
|
|
13
|
+
"self-evident",
|
|
14
|
+
"trivial",
|
|
15
|
+
"obvious",
|
|
16
|
+
"no need to test",
|
|
17
|
+
"covered elsewhere",
|
|
18
|
+
"already tested",
|
|
19
|
+
"just a",
|
|
20
|
+
"only a",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_exemption_rationalization(reason: str) -> list[dict]:
|
|
25
|
+
"""Detect anti-rationalization patterns in TDD exemption reasons.
|
|
26
|
+
|
|
27
|
+
Returns violation dicts with rule_id TDD-RATIONALIZE-001.
|
|
28
|
+
Severity is warn (does not block task start/review).
|
|
29
|
+
"""
|
|
30
|
+
violations: list[dict] = []
|
|
31
|
+
if not reason:
|
|
32
|
+
return violations
|
|
33
|
+
lower = reason.lower()
|
|
34
|
+
for pattern in RATIONALIZATION_PATTERNS:
|
|
35
|
+
if pattern.lower() in lower:
|
|
36
|
+
violations.append({
|
|
37
|
+
"rule_id": "TDD-RATIONALIZE-001",
|
|
38
|
+
"type": "tdd_evidence",
|
|
39
|
+
"severity": "warn",
|
|
40
|
+
"passed": False,
|
|
41
|
+
"message": (
|
|
42
|
+
f"TDD exemption reason contains rationalization pattern: "
|
|
43
|
+
f"'{pattern}'. Replace with a concrete behavior test or "
|
|
44
|
+
f"specific verification command."
|
|
45
|
+
),
|
|
46
|
+
"file": "<task>/tdd.jsonl",
|
|
47
|
+
"fix_hint": (
|
|
48
|
+
"Remove rationalization from exemption reason. "
|
|
49
|
+
"Either write a failing behavior test for this AC "
|
|
50
|
+
"or provide a verification command that exercises "
|
|
51
|
+
"the claimed unchanged behavior."
|
|
52
|
+
),
|
|
53
|
+
})
|
|
54
|
+
break
|
|
55
|
+
return violations
|
|
@@ -58,6 +58,11 @@ class GateRunner:
|
|
|
58
58
|
violations.extend(
|
|
59
59
|
validate_coding_standards(self.repo_root, normalized_task_dir)
|
|
60
60
|
)
|
|
61
|
+
from .validate_coding_standards import validate_complexity_signals
|
|
62
|
+
|
|
63
|
+
violations.extend(
|
|
64
|
+
validate_complexity_signals(self.repo_root, normalized_task_dir)
|
|
65
|
+
)
|
|
61
66
|
return GateResult.from_violations(scope, violations, normalized_task_dir)
|
|
62
67
|
|
|
63
68
|
def log(self, result: GateResult) -> None:
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Spec markdown section validator for cowork-flow."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SECTION_CONFIG: dict[str, tuple[str, ...]] = {
|
|
10
|
+
"contract": ("Goal", "When to Use", "Rules", "Examples"),
|
|
11
|
+
"skill": ("Overview", "When to Use", "Steps", "Verification"),
|
|
12
|
+
"guide": ("Goal", "When to Use", "Process", "Anti-Rationalization"),
|
|
13
|
+
"default": ("Goal", "Rules"),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
HEADER_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_sections(file_path: Path | str, spec_type: str = "default") -> tuple[bool, list[str]]:
|
|
20
|
+
"""Validate a spec markdown file has all required sections.
|
|
21
|
+
|
|
22
|
+
Returns (is_valid, error_messages).
|
|
23
|
+
"""
|
|
24
|
+
path = Path(file_path)
|
|
25
|
+
errors: list[str] = []
|
|
26
|
+
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
return False, [f"File not found: {path}"]
|
|
29
|
+
|
|
30
|
+
content = path.read_text(encoding="utf-8").strip()
|
|
31
|
+
if not content:
|
|
32
|
+
return False, ["File is empty"]
|
|
33
|
+
|
|
34
|
+
required = SECTION_CONFIG.get(spec_type)
|
|
35
|
+
if required is None:
|
|
36
|
+
return False, [f"Unknown spec type: {spec_type}"]
|
|
37
|
+
|
|
38
|
+
matches = list(HEADER_RE.finditer(content))
|
|
39
|
+
found: dict[str, str] = {}
|
|
40
|
+
for i, m in enumerate(matches):
|
|
41
|
+
name = m.group(1).strip()
|
|
42
|
+
start = m.end()
|
|
43
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(content)
|
|
44
|
+
body = content[start:end].strip()
|
|
45
|
+
found[name] = body
|
|
46
|
+
|
|
47
|
+
for req in required:
|
|
48
|
+
if req not in found:
|
|
49
|
+
errors.append(f"Missing: {req}")
|
|
50
|
+
elif not found[req]:
|
|
51
|
+
errors.append(f"Empty: {req}")
|
|
52
|
+
|
|
53
|
+
return (not errors, errors)
|
|
@@ -161,11 +161,7 @@ def _task_requires_tdd(prd_text: str) -> bool:
|
|
|
161
161
|
return any(marker.lower() in lower for marker in TDD_REQUIRED_MARKERS)
|
|
162
162
|
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
try:
|
|
166
|
-
return path.read_text(encoding="utf-8")
|
|
167
|
-
except (OSError, UnicodeDecodeError):
|
|
168
|
-
return ""
|
|
164
|
+
from common.core.files import read_text_utf8 as _read_text
|
|
169
165
|
|
|
170
166
|
|
|
171
167
|
def _read_jsonl(path: Path) -> tuple[list[dict], list[dict]]:
|
|
@@ -343,6 +339,9 @@ def _validate_exemption(
|
|
|
343
339
|
)
|
|
344
340
|
)
|
|
345
341
|
|
|
342
|
+
from .anti_rationalization import validate_exemption_rationalization
|
|
343
|
+
violations.extend(validate_exemption_rationalization(str(entry.get("reason", ""))))
|
|
344
|
+
|
|
346
345
|
return violations
|
|
347
346
|
|
|
348
347
|
|
|
@@ -143,11 +143,7 @@ def _read_tdd_entries(path: Path) -> list[dict]:
|
|
|
143
143
|
return entries
|
|
144
144
|
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
try:
|
|
148
|
-
return path.read_text(encoding="utf-8")
|
|
149
|
-
except (OSError, UnicodeDecodeError):
|
|
150
|
-
return ""
|
|
146
|
+
from common.core.files import read_text_utf8 as _read_text
|
|
151
147
|
|
|
152
148
|
|
|
153
149
|
def _classify_test_content(content: str, test_name: str) -> str:
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""Coding-standard gates — context-injection model.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
``spec/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
Under Approach A, rules authored under ``.cowork-flow/spec/backend/*.md`` and
|
|
5
|
+
``spec/frontend/*.md`` are machine-parsed into checklist items, then surfaced to
|
|
6
|
+
the LLM via ``get_coding_standards_summary`` (already wired into the check/
|
|
7
|
+
review gates). The LLM is trusted to verify its own code against these items.
|
|
8
|
+
|
|
9
|
+
Only machine-decidable checks run here as hard blocks:
|
|
10
|
+
|
|
11
|
+
* Phase 1 — UTF-8 / IO-encoding (``validate_changed_files``).
|
|
12
|
+
* ``validate_complexity_signals`` — oversize Python functions (warn).
|
|
13
|
+
|
|
14
|
+
A separate ``collect_machine_checks`` helper is exposed for callers that want
|
|
15
|
+
additional regex-style warnings (hardcoded secrets, debug prints, silent
|
|
16
|
+
excepts, trailing comments...) WITHOUT hard-blocking the workflow. These are
|
|
17
|
+
emitted at severity=advisory so they can be rendered for the LLM without being a
|
|
18
|
+
wall the user must satisfy.
|
|
9
19
|
"""
|
|
10
20
|
|
|
11
21
|
from __future__ import annotations
|
|
@@ -14,7 +24,7 @@ import json
|
|
|
14
24
|
import re
|
|
15
25
|
import sys
|
|
16
26
|
from pathlib import Path
|
|
17
|
-
from typing import
|
|
27
|
+
from typing import Sequence
|
|
18
28
|
|
|
19
29
|
from common.gates.coding_standards import validate_changed_files
|
|
20
30
|
from common.git.git_snapshot import collect_changed_files, collect_changed_paths
|
|
@@ -39,84 +49,6 @@ def _is_frontend_file(rel_path: str) -> bool:
|
|
|
39
49
|
return bool(_FRONTEND_SUFFIXES.search(rel_path))
|
|
40
50
|
|
|
41
51
|
|
|
42
|
-
# ---------------------------------------------------------------------------
|
|
43
|
-
# Registered NL-backed validators
|
|
44
|
-
# ---------------------------------------------------------------------------
|
|
45
|
-
|
|
46
|
-
# Each entry: (compiled-pattern, category, validator-fn).
|
|
47
|
-
# ``validator(content, rel_path)`` yields ``(lineno, message, fix_hint)``.
|
|
48
|
-
_NL_VALIDATORS: list[tuple[re.Pattern, str, Callable[[str, str], Iterable[tuple[int, str, str]]]]] = []
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def register_nl_validator(pattern: str) -> Callable[[Callable], Callable]:
|
|
52
|
-
def decorator(fn: Callable) -> Callable:
|
|
53
|
-
category = getattr(fn, "_nl_category", "backend")
|
|
54
|
-
_NL_VALIDATORS.append((re.compile(pattern, re.IGNORECASE), category, fn))
|
|
55
|
-
return fn
|
|
56
|
-
return decorator
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def nl_category(category: str) -> Callable[[Callable], Callable]:
|
|
60
|
-
def decorator(fn: Callable) -> Callable:
|
|
61
|
-
fn._nl_category = category
|
|
62
|
-
return fn
|
|
63
|
-
return decorator
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@register_nl_validator(r"(硬编码|hardcode|hard.code|secret|password|api[_\s]?key|token)")
|
|
67
|
-
@nl_category("backend")
|
|
68
|
-
def _no_hardcoded_secrets(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
|
|
69
|
-
pattern = re.compile(
|
|
70
|
-
r"""(?ix)
|
|
71
|
-
(?:password|passwd|secret|api[_\s]?key|token|access[_\s]?key|aws_access_key_id|aws_secret_access_key)
|
|
72
|
-
\s*[:=]\s*["'][^"'}{)($]{3,}["']
|
|
73
|
-
"""
|
|
74
|
-
)
|
|
75
|
-
for lineno, raw in enumerate(content.splitlines(), start=1):
|
|
76
|
-
if pattern.search(raw) and "process.env" not in raw and "os.environ" not in raw:
|
|
77
|
-
yield (lineno,
|
|
78
|
-
"Possible hard-coded secret in source; spec forbids it.",
|
|
79
|
-
"Read from configuration or environment variables.")
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
@register_nl_validator(r"(print\b|console\.(log|info|debug)|日志|logger|logging|结构化日志)")
|
|
83
|
-
@nl_category("backend")
|
|
84
|
-
def _no_debug_prints(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
|
|
85
|
-
if rel_path.endswith(".py"):
|
|
86
|
-
pat = re.compile(r"(?<!\w)print\s*\(")
|
|
87
|
-
elif rel_path.endswith((".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx")):
|
|
88
|
-
pat = re.compile(r"\bconsole\.(log|info|debug)\s*\(")
|
|
89
|
-
else:
|
|
90
|
-
return
|
|
91
|
-
for lineno, raw in enumerate(content.splitlines(), start=1):
|
|
92
|
-
if pat.search(raw):
|
|
93
|
-
yield (lineno, "Debug print found; spec prefers structured logger.",
|
|
94
|
-
"Use a structured logger instead.")
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
@register_nl_validator(r"(吞|swallow|静默|except|空\s*except)")
|
|
98
|
-
@nl_category("backend")
|
|
99
|
-
def _no_silent_except(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
|
|
100
|
-
if not rel_path.endswith(".py"):
|
|
101
|
-
return
|
|
102
|
-
lines = content.splitlines()
|
|
103
|
-
for lineno, raw in enumerate(lines, start=1):
|
|
104
|
-
if re.match(r"^\s*except\b\s*.*:\s*(#.*)?$", raw):
|
|
105
|
-
nxt = lines[lineno].strip() if lineno < len(lines) else ""
|
|
106
|
-
if nxt in {"pass", "...", ""} or nxt.startswith(("pass ", "...")):
|
|
107
|
-
yield (lineno, "Empty exception handler silently swallows errors.",
|
|
108
|
-
"Log or re-raise the exception, or narrow the except types.")
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
@register_nl_validator(r"(硬编码|hardcode|明文|magic\s+number)")
|
|
112
|
-
@nl_category("frontend")
|
|
113
|
-
def _no_magic_values_frontend(content: str, rel_path: str) -> Iterable[tuple[int, str, str]]:
|
|
114
|
-
pat = re.compile(r"""(?ix)(?:magic\s+number|hardcode|硬编码)""")
|
|
115
|
-
for lineno, raw in enumerate(content.splitlines(), start=1):
|
|
116
|
-
if pat.search(raw):
|
|
117
|
-
yield (lineno, "Hard-coded magic value in frontend source; spec forbids it.")
|
|
118
|
-
|
|
119
|
-
|
|
120
52
|
# ---------------------------------------------------------------------------
|
|
121
53
|
# Spec parser
|
|
122
54
|
# ---------------------------------------------------------------------------
|
|
@@ -148,6 +80,8 @@ def load_spec_rules(
|
|
|
148
80
|
"""Return every natural-language rule declared under ``spec/<category>/``.
|
|
149
81
|
|
|
150
82
|
Re-reads markdown on every call, so user edits take effect without restart.
|
|
83
|
+
``validators`` is preserved as an empty list (legacy field kept so external
|
|
84
|
+
tooling that inspects the rule dict does not break).
|
|
151
85
|
"""
|
|
152
86
|
out: list[dict] = []
|
|
153
87
|
repo_root = Path(repo_root)
|
|
@@ -168,10 +102,7 @@ def load_spec_rules(
|
|
|
168
102
|
"source": f".cowork-flow/spec/{category}/{md.name}",
|
|
169
103
|
"line": lineno,
|
|
170
104
|
"text": rule,
|
|
171
|
-
"validators": [
|
|
172
|
-
pat.pattern for pat, cat, _ in _NL_VALIDATORS
|
|
173
|
-
if cat in (category, "backend") and re.search(pat, rule)
|
|
174
|
-
],
|
|
105
|
+
"validators": [],
|
|
175
106
|
})
|
|
176
107
|
return out
|
|
177
108
|
|
|
@@ -180,12 +111,15 @@ def load_spec_rules(
|
|
|
180
111
|
# Public API
|
|
181
112
|
# ---------------------------------------------------------------------------
|
|
182
113
|
|
|
183
|
-
|
|
184
114
|
def get_coding_standards_summary(
|
|
185
115
|
repo_root: Path,
|
|
186
116
|
task_dir: Path,
|
|
187
117
|
) -> str:
|
|
188
|
-
"""Render the active user-authored rules as a checklist (read-only).
|
|
118
|
+
"""Render the active user-authored rules as a review checklist (read-only).
|
|
119
|
+
|
|
120
|
+
Calls whose changed paths cover neither backend nor frontend files return
|
|
121
|
+
an empty string — there is nothing actionable for the LLM to review.
|
|
122
|
+
"""
|
|
189
123
|
changed = collect_changed_paths(repo_root)
|
|
190
124
|
if not changed:
|
|
191
125
|
return ""
|
|
@@ -199,13 +133,13 @@ def get_coding_standards_summary(
|
|
|
199
133
|
if backend:
|
|
200
134
|
rules = load_spec_rules(repo_root, ("backend",))
|
|
201
135
|
if rules:
|
|
202
|
-
out.append("=== Backend spec rules —
|
|
136
|
+
out.append("=== Backend spec rules — LLM review checklist ===")
|
|
203
137
|
for r in rules:
|
|
204
138
|
out.append(f"- [{r['source']}:{r['line']}] {r['text']}")
|
|
205
139
|
if frontend:
|
|
206
140
|
rules = load_spec_rules(repo_root, ("frontend",))
|
|
207
141
|
if rules:
|
|
208
|
-
out.append("=== Frontend spec rules —
|
|
142
|
+
out.append("=== Frontend spec rules — LLM review checklist ===")
|
|
209
143
|
for r in rules:
|
|
210
144
|
out.append(f"- [{r['source']}:{r['line']}] {r['text']}")
|
|
211
145
|
return "\n".join(out)
|
|
@@ -215,28 +149,44 @@ def validate_coding_standards(
|
|
|
215
149
|
repo_root: Path,
|
|
216
150
|
task_dir: Path | None = None,
|
|
217
151
|
) -> list[dict]:
|
|
218
|
-
"""Run
|
|
152
|
+
"""Run hard machine-checkable coding-standard gates.
|
|
219
153
|
|
|
220
|
-
|
|
221
|
-
|
|
154
|
+
Approach A keeps only the UTF-8 / IO-encoding check here. All natural
|
|
155
|
+
language rules authored in the spec tree are surfaced to the LLM via the
|
|
156
|
+
checklist in ``get_coding_standards_summary``; they do not block the
|
|
157
|
+
workflow from this function. Optional advisory warnings live in
|
|
158
|
+
``collect_machine_checks``.
|
|
222
159
|
"""
|
|
223
|
-
|
|
160
|
+
return list(validate_changed_files(repo_root, collect_changed_files(repo_root)))
|
|
224
161
|
|
|
225
|
-
# Phase 1: hardcoded UTF-8 / IO checks always run.
|
|
226
|
-
violations.extend(validate_changed_files(repo_root, collect_changed_files(repo_root)))
|
|
227
162
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
163
|
+
def collect_machine_checks(
|
|
164
|
+
repo_root: Path,
|
|
165
|
+
task_dir: Path | None = None,
|
|
166
|
+
) -> list[dict]:
|
|
167
|
+
"""Emit regex-style advisory signals (severity=advisory).
|
|
232
168
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
169
|
+
The LLM decides how to act on them; the workflow never blocks on a hit.
|
|
170
|
+
Kept separate from ``validate_coding_standards`` so callers can choose
|
|
171
|
+
whether to render them in a gate log, inject them into context, or ignore.
|
|
172
|
+
"""
|
|
173
|
+
violations: list[dict] = []
|
|
174
|
+
try:
|
|
175
|
+
changed_files = collect_changed_files(repo_root)
|
|
176
|
+
except Exception: # noqa: BLE001 — defensively isolate git errors
|
|
237
177
|
return violations
|
|
238
178
|
|
|
239
|
-
|
|
179
|
+
secret_re = re.compile(
|
|
180
|
+
r"""(?ix)
|
|
181
|
+
(?:password|passwd|secret|api[_\s]?key|token|access[_\s]?key)
|
|
182
|
+
\s*[:=]\s*["'][^"'}{)($]{3,}["']
|
|
183
|
+
"""
|
|
184
|
+
)
|
|
185
|
+
silent_except_re = re.compile(r"^\s*except\b\s*.*:\s*(#.*)?$")
|
|
186
|
+
bare_print_re = re.compile(r"(?<!\w)print\s*\(")
|
|
187
|
+
console_re = re.compile(r"\bconsole\.(log|info|debug)\s*\(")
|
|
188
|
+
|
|
189
|
+
for changed_file in changed_files:
|
|
240
190
|
file_path = repo_root / changed_file.path
|
|
241
191
|
if not file_path.is_file():
|
|
242
192
|
continue
|
|
@@ -247,38 +197,125 @@ def validate_coding_standards(
|
|
|
247
197
|
content = file_path.read_text(encoding="utf-8")
|
|
248
198
|
except (OSError, UnicodeDecodeError):
|
|
249
199
|
continue
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
"
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
"
|
|
266
|
-
"line"
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
200
|
+
lines = content.splitlines()
|
|
201
|
+
for lineno, raw in enumerate(lines, start=1):
|
|
202
|
+
# Low-signal heuristic: only flag if the line also has an
|
|
203
|
+
# assignment / call so variable-name mentions do not trip it.
|
|
204
|
+
if (secret_re.search(raw)
|
|
205
|
+
and "process.env" not in raw
|
|
206
|
+
and "os.environ" not in raw):
|
|
207
|
+
violations.append(_adv(
|
|
208
|
+
"MACHINE-HARDCODED-SECRET-001",
|
|
209
|
+
"Possible hard-coded secret in source",
|
|
210
|
+
changed_file.path, lineno,
|
|
211
|
+
"Read the value from configuration or environment variables.",
|
|
212
|
+
))
|
|
213
|
+
if suffix == ".py" and bare_print_re.search(raw):
|
|
214
|
+
violations.append(_adv(
|
|
215
|
+
"MACHINE-DEBUG-PRINT-001",
|
|
216
|
+
"Debug print on changed line",
|
|
217
|
+
changed_file.path, lineno,
|
|
218
|
+
"Use a structured logger or remove the print.",
|
|
219
|
+
))
|
|
220
|
+
if suffix in {".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"} and console_re.search(raw):
|
|
221
|
+
violations.append(_adv(
|
|
222
|
+
"MACHINE-CONSOLE-LOG-001",
|
|
223
|
+
"console.{log,info,debug} on changed line",
|
|
224
|
+
changed_file.path, lineno,
|
|
225
|
+
"Use the project logger or remove the call.",
|
|
226
|
+
))
|
|
227
|
+
if (suffix == ".py"
|
|
228
|
+
and silent_except_re.match(raw)
|
|
229
|
+
and lineno < len(lines)
|
|
230
|
+
and lines[lineno].strip() in {"pass", "...", ""}):
|
|
231
|
+
violations.append(_adv(
|
|
232
|
+
"MACHINE-SILENT-EXCEPT-001",
|
|
233
|
+
"Empty exception handler silently swallows errors",
|
|
234
|
+
changed_file.path, lineno,
|
|
235
|
+
"Log or re-raise the exception, or narrow the except types.",
|
|
236
|
+
))
|
|
270
237
|
return violations
|
|
271
238
|
|
|
272
239
|
|
|
240
|
+
def _adv(
|
|
241
|
+
rule_id: str,
|
|
242
|
+
message: str,
|
|
243
|
+
file_path: str,
|
|
244
|
+
line: int,
|
|
245
|
+
fix_hint: str,
|
|
246
|
+
) -> dict:
|
|
247
|
+
return {
|
|
248
|
+
"rule_id": rule_id,
|
|
249
|
+
"type": "machine_check",
|
|
250
|
+
"severity": "advisory",
|
|
251
|
+
"passed": False,
|
|
252
|
+
"message": message,
|
|
253
|
+
"file": file_path,
|
|
254
|
+
"line": line,
|
|
255
|
+
"fix_hint": fix_hint,
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def validate_complexity_signals(repo_root: Path, task_dir: Path | None) -> list[dict]:
|
|
260
|
+
"""Detect over-complexity signals in modified files (warn-level)."""
|
|
261
|
+
import ast
|
|
262
|
+
import subprocess
|
|
263
|
+
warnings: list[dict] = []
|
|
264
|
+
try:
|
|
265
|
+
result = subprocess.run(
|
|
266
|
+
["git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD~"],
|
|
267
|
+
cwd=str(repo_root), capture_output=True, text=True,
|
|
268
|
+
encoding="utf-8", timeout=10,
|
|
269
|
+
)
|
|
270
|
+
changed_files = [f for f in result.stdout.splitlines() if f]
|
|
271
|
+
except (subprocess.SubprocessError, OSError):
|
|
272
|
+
return warnings
|
|
273
|
+
|
|
274
|
+
for rel_path in changed_files:
|
|
275
|
+
if not rel_path.endswith(".py"):
|
|
276
|
+
continue
|
|
277
|
+
full_path = repo_root / rel_path
|
|
278
|
+
if not full_path.is_file():
|
|
279
|
+
continue
|
|
280
|
+
try:
|
|
281
|
+
source = full_path.read_text(encoding="utf-8")
|
|
282
|
+
tree = ast.parse(source)
|
|
283
|
+
except (OSError, SyntaxError, UnicodeDecodeError):
|
|
284
|
+
continue
|
|
285
|
+
for node in ast.walk(tree):
|
|
286
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
287
|
+
end = getattr(node, "end_lineno", None) or node.lineno
|
|
288
|
+
func_lines = end - node.lineno
|
|
289
|
+
if func_lines > 50:
|
|
290
|
+
warnings.append({
|
|
291
|
+
"rule_id": "COMPLEX-FUNC-001",
|
|
292
|
+
"type": "coding_standard",
|
|
293
|
+
"severity": "warn",
|
|
294
|
+
"passed": False,
|
|
295
|
+
"message": (
|
|
296
|
+
f"{rel_path}:{node.name} function is "
|
|
297
|
+
f"{func_lines} lines (< 50 recommended)"
|
|
298
|
+
),
|
|
299
|
+
"file": str(rel_path),
|
|
300
|
+
"fix_hint": (
|
|
301
|
+
"Split into multiple named functions "
|
|
302
|
+
"by responsibility."
|
|
303
|
+
),
|
|
304
|
+
})
|
|
305
|
+
return warnings
|
|
306
|
+
|
|
307
|
+
|
|
273
308
|
def main() -> None:
|
|
274
309
|
import argparse
|
|
275
310
|
|
|
276
|
-
parser = argparse.ArgumentParser(description="
|
|
311
|
+
parser = argparse.ArgumentParser(description="Coding-standard gate")
|
|
277
312
|
parser.add_argument("--task-dir", help="Task directory")
|
|
278
313
|
parser.add_argument("--repo-root", default=".", help="Repo root path")
|
|
279
314
|
parser.add_argument("--summarize", action="store_true")
|
|
280
315
|
parser.add_argument("--list", action="store_true", help="List parsed spec rules")
|
|
281
|
-
parser.add_argument("--validate", action="store_true"
|
|
316
|
+
parser.add_argument("--validate", action="store_true")
|
|
317
|
+
parser.add_argument("--machine-checks", action="store_true",
|
|
318
|
+
help="Also emit advisory machine-check warnings")
|
|
282
319
|
|
|
283
320
|
args = parser.parse_args()
|
|
284
321
|
repo_root = Path(args.repo_root).resolve()
|
|
@@ -293,9 +330,11 @@ def main() -> None:
|
|
|
293
330
|
return
|
|
294
331
|
if args.validate:
|
|
295
332
|
v = validate_coding_standards(repo_root, task_dir)
|
|
333
|
+
if args.machine_checks:
|
|
334
|
+
v.extend(collect_machine_checks(repo_root, task_dir))
|
|
296
335
|
for x in v:
|
|
297
336
|
print(json.dumps(x, ensure_ascii=False))
|
|
298
|
-
sys.exit(1 if v else 0)
|
|
337
|
+
sys.exit(1 if any(x["severity"] == "block" for x in v) else 0)
|
|
299
338
|
print(get_coding_standards_summary(repo_root, task_dir) or "No spec rules activated.")
|
|
300
339
|
|
|
301
340
|
|