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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/template/.cowork-flow/.version +1 -1
  3. package/template/.cowork-flow/scripts/commands/task.py +28 -1
  4. package/template/.cowork-flow/scripts/common/core/files.py +8 -0
  5. package/template/.cowork-flow/scripts/common/gates/anti_rationalization.py +55 -0
  6. package/template/.cowork-flow/scripts/common/gates/gates.py +5 -0
  7. package/template/.cowork-flow/scripts/common/gates/spec_validator.py +53 -0
  8. package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +4 -5
  9. package/template/.cowork-flow/scripts/common/gates/test_intent.py +1 -5
  10. package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +170 -131
  11. package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +32 -0
  12. package/template/.cowork-flow/scripts/common/gates/validate_rules.py +1 -12
  13. package/template/.cowork-flow/scripts/common/task/batch_mode.py +145 -0
  14. package/template/.cowork-flow/scripts/common/task/readiness.py +22 -4
  15. package/template/.cowork-flow/spec/backend/quality-guidelines.md +2 -0
  16. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +54 -0
  17. package/template/.cowork-flow/spec/frontend/quality-guidelines.md +2 -0
  18. package/template/.cowork-flow/spec/index.md +16 -0
  19. package/template/.cowork-flow/spec/references/definition-of-done.md +45 -0
  20. package/template/.cowork-flow/spec/references/index.md +20 -0
  21. package/template/.cowork-flow/spec/references/orchestration-patterns.md +139 -0
  22. package/template/.cowork-flow/spec/references/security-checklist.md +51 -0
  23. package/template/.cowork-flow/spec/references/testing-checklist.md +45 -0
  24. package/template/.cowork-flow/spec/runtime/contract-registry.json +57 -0
  25. package/template/.cowork-flow/spec/runtime/rules.json +60 -0
  26. package/template/.cowork-flow/workflow.md +5 -0
  27. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  28. package/template/.zcode/hooks/runtime/scripts/common/core/files.py +8 -0
  29. package/template/.zcode/hooks/runtime/scripts/common/gates/anti_rationalization.py +55 -0
  30. package/template/.zcode/hooks/runtime/scripts/common/gates/gates.py +5 -0
  31. package/template/.zcode/hooks/runtime/scripts/common/gates/tdd_evidence.py +4 -5
  32. package/template/.zcode/hooks/runtime/scripts/common/gates/test_intent.py +1 -5
  33. package/template/.zcode/hooks/runtime/scripts/common/gates/validate_coding_standards.py +294 -133
  34. package/template/.zcode/hooks/runtime/scripts/common/gates/validate_rules.py +2 -13
  35. package/template/.zcode/hooks/runtime/scripts/common/task/readiness.py +4 -4
  36. package/template/AGENTS.md +9 -0
  37. package/template/skills/batch-mode/SKILL.md +117 -0
  38. package/template/skills/before-dev/SKILL.md +8 -0
  39. package/template/skills/break-loop/SKILL.md +53 -0
  40. package/template/skills/check/SKILL.md +63 -0
  41. package/template/skills/doubt-review/SKILL.md +118 -0
  42. package/template/skills/tdd/SKILL.md +13 -0
  43. package/template/skills/writing-plans/SKILL.md +23 -0
  44. package/template/.zcode/hooks/runtime/scripts/common/gates/validate_coding_standaries.py +0 -316
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.32",
3
+ "version": "0.0.33",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- 0.0.32
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, commit, archive, and record session")
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
- def _read_text(path: Path) -> str:
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
- def _read_text(path: Path) -> str:
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
- """Spec-driven coding standards — gates honor user-edited spec files.
2
+ """Coding-standard gates — context-injection model.
3
3
 
4
- Files under ``.cowork-flow/spec/backend/``, ``spec/frontend/`` and
5
- ``spec/guides/`` are parsed at runtime into review-checklist rules and matched
6
- against a small registry of registered validators. Anything a user adds to
7
- those files is activated at the next gate run — the gate re-reads the
8
- markdown every time.
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 Callable, Iterable, Sequence
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 — VALIDATED during check gate ===")
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 — VALIDATED during check gate ===")
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 all coding-standard checks — hardcoded UTF-8 + spec-activated NL rules.
152
+ """Run hard machine-checkable coding-standard gates.
219
153
 
220
- The NL rules only run when the user's spec files declare a matching rule
221
- text, making spec files genuinely editable quality gates.
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
- violations: list[dict] = []
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
- # Phase 2: spec-driven validators — activated by user-edited spec text.
229
- spec_rules = load_spec_rules(repo_root)
230
- if not spec_rules:
231
- return violations
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
- activated: set[str] = set()
234
- for rule in spec_rules:
235
- activated.update(rule.get("validators", []))
236
- if not activated:
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
- for changed_file in collect_changed_files(repo_root):
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
- for pat, _category, validator in _NL_VALIDATORS:
251
- if pat.pattern not in activated:
252
- continue
253
- try:
254
- results = validator(content, changed_file.path)
255
- except Exception: # noqa: BLE001 — defensively isolate parsing
256
- continue
257
- for lineno, msg, *rest in results:
258
- fix_hint = rest[0] if rest else ""
259
- violations.append({
260
- "rule_id": f"SPEC-{validator.__name__}",
261
- "type": "spec_coding_standard",
262
- "severity": "block",
263
- "passed": False,
264
- "message": msg,
265
- "file": changed_file.path,
266
- "line": lineno,
267
- "fix_hint": fix_hint,
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="Spec-driven coding gate")
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", help="Run spec validators vs changed files")
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