@miller-tech/uap 1.222.0 → 2.0.0

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 (234) hide show
  1. package/README.md +19 -9
  2. package/config/exl3-profiles/qwen38-27b-exl3.env +68 -0
  3. package/config/llama-profiles/qwen38-27b-dflash2.env +6 -1
  4. package/dist/.tsbuildinfo +1 -1
  5. package/dist/benchmarks/paired/adapter.d.ts +15 -0
  6. package/dist/benchmarks/paired/adapter.d.ts.map +1 -1
  7. package/dist/benchmarks/paired/adapter.js +22 -1
  8. package/dist/benchmarks/paired/adapter.js.map +1 -1
  9. package/dist/bin/cli.js +20 -24
  10. package/dist/bin/cli.js.map +1 -1
  11. package/dist/cli/deliver.d.ts +34 -0
  12. package/dist/cli/deliver.d.ts.map +1 -1
  13. package/dist/cli/deliver.js +147 -20
  14. package/dist/cli/deliver.js.map +1 -1
  15. package/dist/cli/guided-setup.d.ts.map +1 -1
  16. package/dist/cli/guided-setup.js +15 -0
  17. package/dist/cli/guided-setup.js.map +1 -1
  18. package/dist/cli/init.d.ts.map +1 -1
  19. package/dist/cli/init.js +3 -0
  20. package/dist/cli/init.js.map +1 -1
  21. package/dist/cli/plan.d.ts.map +1 -1
  22. package/dist/cli/plan.js +8 -2
  23. package/dist/cli/plan.js.map +1 -1
  24. package/dist/cli/policy.d.ts.map +1 -1
  25. package/dist/cli/policy.js +104 -2
  26. package/dist/cli/policy.js.map +1 -1
  27. package/dist/cli/quality.d.ts +11 -0
  28. package/dist/cli/quality.d.ts.map +1 -0
  29. package/dist/cli/quality.js +213 -0
  30. package/dist/cli/quality.js.map +1 -0
  31. package/dist/cli/wizard-config.d.ts.map +1 -1
  32. package/dist/cli/wizard-config.js +19 -9
  33. package/dist/cli/wizard-config.js.map +1 -1
  34. package/dist/cli/worktree.d.ts.map +1 -1
  35. package/dist/cli/worktree.js +8 -1
  36. package/dist/cli/worktree.js.map +1 -1
  37. package/dist/dashboard/savings.d.ts +1 -4
  38. package/dist/dashboard/savings.d.ts.map +1 -1
  39. package/dist/dashboard/savings.js +2 -52
  40. package/dist/dashboard/savings.js.map +1 -1
  41. package/dist/delivery/acceptance-judge.d.ts +10 -0
  42. package/dist/delivery/acceptance-judge.d.ts.map +1 -1
  43. package/dist/delivery/acceptance-judge.js +45 -3
  44. package/dist/delivery/acceptance-judge.js.map +1 -1
  45. package/dist/delivery/agentic-executor.d.ts +12 -1
  46. package/dist/delivery/agentic-executor.d.ts.map +1 -1
  47. package/dist/delivery/agentic-executor.js +25 -5
  48. package/dist/delivery/agentic-executor.js.map +1 -1
  49. package/dist/delivery/bash-sweep.d.ts +12 -0
  50. package/dist/delivery/bash-sweep.d.ts.map +1 -1
  51. package/dist/delivery/bash-sweep.js +8 -1
  52. package/dist/delivery/bash-sweep.js.map +1 -1
  53. package/dist/delivery/convergence-loop.d.ts.map +1 -1
  54. package/dist/delivery/convergence-loop.js +6 -4
  55. package/dist/delivery/convergence-loop.js.map +1 -1
  56. package/dist/delivery/execution-gate.d.ts +1 -1
  57. package/dist/delivery/execution-gate.d.ts.map +1 -1
  58. package/dist/delivery/execution-gate.js +248 -5
  59. package/dist/delivery/execution-gate.js.map +1 -1
  60. package/dist/delivery/snapshot.d.ts +16 -0
  61. package/dist/delivery/snapshot.d.ts.map +1 -1
  62. package/dist/delivery/snapshot.js +113 -2
  63. package/dist/delivery/snapshot.js.map +1 -1
  64. package/dist/delivery/user-validation.d.ts.map +1 -1
  65. package/dist/delivery/user-validation.js +55 -10
  66. package/dist/delivery/user-validation.js.map +1 -1
  67. package/dist/delivery/verifier-ladder.d.ts +24 -0
  68. package/dist/delivery/verifier-ladder.d.ts.map +1 -1
  69. package/dist/delivery/verifier-ladder.js +119 -2
  70. package/dist/delivery/verifier-ladder.js.map +1 -1
  71. package/dist/generators/claude-md.js +17 -4
  72. package/dist/generators/claude-md.js.map +1 -1
  73. package/dist/policies/liveness.d.ts +105 -0
  74. package/dist/policies/liveness.d.ts.map +1 -0
  75. package/dist/policies/liveness.js +251 -0
  76. package/dist/policies/liveness.js.map +1 -0
  77. package/dist/policies/policy-memory.d.ts.map +1 -1
  78. package/dist/policies/policy-memory.js +3 -4
  79. package/dist/policies/policy-memory.js.map +1 -1
  80. package/dist/quality/baseline.d.ts +29 -0
  81. package/dist/quality/baseline.d.ts.map +1 -0
  82. package/dist/quality/baseline.js +79 -0
  83. package/dist/quality/baseline.js.map +1 -0
  84. package/dist/quality/complexity.d.ts +38 -0
  85. package/dist/quality/complexity.d.ts.map +1 -0
  86. package/dist/quality/complexity.js +239 -0
  87. package/dist/quality/complexity.js.map +1 -0
  88. package/dist/quality/config.d.ts +40 -0
  89. package/dist/quality/config.d.ts.map +1 -0
  90. package/dist/quality/config.js +77 -0
  91. package/dist/quality/config.js.map +1 -0
  92. package/dist/quality/crap.d.ts +11 -0
  93. package/dist/quality/crap.d.ts.map +1 -0
  94. package/dist/quality/crap.js +54 -0
  95. package/dist/quality/crap.js.map +1 -0
  96. package/dist/quality/languages.d.ts +25 -0
  97. package/dist/quality/languages.d.ts.map +1 -0
  98. package/dist/quality/languages.js +150 -0
  99. package/dist/quality/languages.js.map +1 -0
  100. package/dist/quality/report.d.ts +38 -0
  101. package/dist/quality/report.d.ts.map +1 -0
  102. package/dist/quality/report.js +251 -0
  103. package/dist/quality/report.js.map +1 -0
  104. package/dist/quality/scanner.d.ts +32 -0
  105. package/dist/quality/scanner.d.ts.map +1 -0
  106. package/dist/quality/scanner.js +127 -0
  107. package/dist/quality/scanner.js.map +1 -0
  108. package/dist/quality/tools.d.ts +28 -0
  109. package/dist/quality/tools.d.ts.map +1 -0
  110. package/dist/quality/tools.js +189 -0
  111. package/dist/quality/tools.js.map +1 -0
  112. package/dist/types/config.d.ts +148 -0
  113. package/dist/types/config.d.ts.map +1 -1
  114. package/dist/types/config.js +35 -0
  115. package/dist/types/config.js.map +1 -1
  116. package/docs/ATTRIBUTION.md +0 -1
  117. package/docs/INDEX.md +38 -4
  118. package/docs/analysis/MARKET_POSITION.md +179 -0
  119. package/docs/architecture/OVERVIEW.md +82 -18
  120. package/docs/architecture/PROTOCOL.md +1 -1
  121. package/docs/benchmarks/PAIRED_FINDINGS.md +106 -0
  122. package/docs/benchmarks/PAIRED_HARNESS.md +16 -0
  123. package/docs/benchmarks/README.md +59 -0
  124. package/docs/design/UAP_REACTOR.md +0 -1
  125. package/docs/getting-started/INSTALLATION.md +1 -1
  126. package/docs/guides/AUTOMATIC.md +0 -14
  127. package/docs/guides/AUTOMATIC_FEATURES.md +1 -9
  128. package/docs/guides/COORDINATION.md +1 -1
  129. package/docs/guides/DELIVERY_PIPELINE.md +5 -0
  130. package/docs/guides/DEPLOY_BATCHING.md +1 -1
  131. package/docs/guides/DROIDS_AND_SKILLS.md +1 -1
  132. package/docs/guides/LOCAL_MODELS.md +6 -1
  133. package/docs/guides/MCP_ROUTER.md +1 -1
  134. package/docs/guides/MEMORY.md +1 -1
  135. package/docs/guides/MULTI_MODEL.md +1 -1
  136. package/docs/guides/ORCHESTRATOR.md +1 -1
  137. package/docs/guides/POLICIES.md +19 -3
  138. package/docs/guides/QWEN38_EXL3.md +114 -0
  139. package/docs/guides/WORKTREE_WORKFLOW.md +45 -1
  140. package/docs/integrations/MCP_ROUTER.md +1 -4
  141. package/docs/plans/deliver-hardening-plan-2026-07-13.md +173 -0
  142. package/docs/plans/deliver-hardening-review-response-2026-07-13.md +180 -0
  143. package/docs/plans/k3-uap-uplift-validation.md +216 -0
  144. package/docs/plans/qwen38-uap-retest-plan.md +196 -0
  145. package/docs/reference/API.md +1 -1
  146. package/docs/reference/CLI.md +49 -14
  147. package/docs/reference/COMPARISON.md +248 -0
  148. package/docs/reference/CONFIGURATION.md +1 -3
  149. package/docs/reference/DATABASE_SCHEMA.md +1 -1
  150. package/docs/reference/FEATURES.md +3 -3
  151. package/docs/reference/PATTERNS.md +1 -1
  152. package/docs/reference/PLATFORMS.md +1 -1
  153. package/package.json +2 -2
  154. package/scripts/maintenance/update-skills.py +0 -22
  155. package/scripts/maintenance/verify-compliance.sh +3 -3
  156. package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
  157. package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
  158. package/src/policies/enforcers/adr_guard.py +0 -0
  159. package/src/policies/enforcers/bearer_lockdown.py +0 -0
  160. package/src/policies/enforcers/branch_freshness.py +0 -0
  161. package/src/policies/enforcers/design_token_gate.py +0 -0
  162. package/src/policies/enforcers/enforcement_self_protect.py +70 -0
  163. package/src/policies/enforcers/expert_review_required.py +22 -0
  164. package/src/policies/enforcers/iac_plan_destruction_check.py +0 -0
  165. package/src/policies/enforcers/quality_metrics_gate.py +447 -0
  166. package/src/policies/enforcers/ship_loop_gate.py +0 -0
  167. package/src/policies/enforcers/validate_plan_on_change.py +0 -0
  168. package/src/policies/enforcers/visual_verification.py +0 -0
  169. package/src/policies/enforcers/workdir_scope.py +0 -0
  170. package/src/policies/schemas/policies/expert-review-required.md +9 -1
  171. package/src/policies/schemas/policies/quality-metrics-gate.md +74 -0
  172. package/templates/CLAUDE.template.md +1 -1
  173. package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
  174. package/templates/hooks/pre-tool-use-bash.sh +50 -3
  175. package/templates/hooks/pre-tool-use-edit-write.sh +31 -0
  176. package/templates/hooks/session-start.sh +15 -0
  177. package/templates/hooks/uap-policy-gate.sh +187 -5
  178. package/templates/stryker.config.json +14 -0
  179. package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
  180. package/tools/agents/scripts/anthropic_proxy.py +254 -28
  181. package/tools/agents/tests/run_acceptance.py +0 -0
  182. package/tools/agents/tests/test_client_disconnect.py +0 -0
  183. package/tools/agents/tests/test_cycle_break_exploration.py +0 -0
  184. package/tools/agents/tests/test_deferral_break.py +0 -0
  185. package/tools/agents/tests/test_delivery_enforcement_all_langs.py +0 -0
  186. package/tools/agents/tests/test_delivery_enforcement_exemptions.py +0 -0
  187. package/tools/agents/tests/test_delivery_enforcement_filepath.py +0 -0
  188. package/tools/agents/tests/test_delivery_enforcement_web_and_bash.py +0 -0
  189. package/tools/agents/tests/test_delivery_enforcement_worktree.py +0 -0
  190. package/tools/agents/tests/test_delivery_enforcement_write_complexity.py +0 -0
  191. package/tools/agents/tests/test_disconnect_watcher.py +0 -0
  192. package/tools/agents/tests/test_doubling_break.py +0 -0
  193. package/tools/agents/tests/test_empty_maxtokens_recovery.py +0 -0
  194. package/tools/agents/tests/test_empty_tool_loop_break.py +0 -0
  195. package/tools/agents/tests/test_enforcement_self_protect.py +90 -0
  196. package/tools/agents/tests/test_enforcer_escape_hatches.py +0 -0
  197. package/tools/agents/tests/test_error_loop_break.py +0 -0
  198. package/tools/agents/tests/test_expert_review_pr_scope.py +13 -5
  199. package/tools/agents/tests/test_fastpath_config.py +0 -0
  200. package/tools/agents/tests/test_finalize_suppression.py +0 -0
  201. package/tools/agents/tests/test_gate_evidence.py +0 -0
  202. package/tools/agents/tests/test_gate_failclosed_indirection.py +0 -0
  203. package/tools/agents/tests/test_gate_integrity.py +0 -0
  204. package/tools/agents/tests/test_live_deliver_kill.py +0 -0
  205. package/tools/agents/tests/test_malformed_unclosed_think.py +0 -0
  206. package/tools/agents/tests/test_mandate_beats_recon.py +0 -0
  207. package/tools/agents/tests/test_mandate_deliver.py +0 -0
  208. package/tools/agents/tests/test_non_stream_resp_log.py +0 -0
  209. package/tools/agents/tests/test_output_token_ceilings.py +0 -0
  210. package/tools/agents/tests/test_overflow_truncate_count_tokens.py +0 -0
  211. package/tools/agents/tests/test_path_containment.py +0 -0
  212. package/tools/agents/tests/test_path_normalizer_hardened.py +0 -0
  213. package/tools/agents/tests/test_repeat_call_guard.py +0 -0
  214. package/tools/agents/tests/test_repeat_guard.py +0 -0
  215. package/tools/agents/tests/test_session_admission.py +0 -0
  216. package/tools/agents/tests/test_stream_heartbeat.py +0 -0
  217. package/tools/agents/tests/test_stream_thinking_deltas.py +405 -0
  218. package/tools/agents/tests/test_stuck_break_hard.py +0 -0
  219. package/tools/agents/tests/test_stuck_break_reattach.py +0 -0
  220. package/tools/agents/tests/test_turn_count_breaker_periodic.py +0 -0
  221. package/tools/agents/tests/test_upstream_chokepoint.py +0 -0
  222. package/tools/agents/tests/test_upstream_model_aliases.py +0 -0
  223. package/tools/agents/tests/test_validate_plan_gate.py +0 -0
  224. package/tools/agents/tests/test_vision_passthrough.py +0 -0
  225. package/tools/agents/tests/test_workdir_scope_enforcer.py +0 -0
  226. package/dist/cli/rtk.d.ts +0 -34
  227. package/dist/cli/rtk.d.ts.map +0 -1
  228. package/dist/cli/rtk.js +0 -401
  229. package/dist/cli/rtk.js.map +0 -1
  230. package/docs/integrations/RTK.md +0 -113
  231. package/scripts/setup/install-rtk.sh +0 -184
  232. package/src/policies/enforcers/rtk_wrap.py +0 -205
  233. package/src/policies/schemas/policies/rtk-wrap.md +0 -50
  234. package/tools/agents/tests/test_rtk_wrap.py +0 -151
@@ -0,0 +1,447 @@
1
+ #!/usr/bin/env python3
2
+ """quality-metrics gate: edits must not introduce NEW or WORSENED metric debt.
3
+
4
+ A Write/Edit/MultiEdit to a source file whose post-edit content violates the
5
+ thresholds in `.uap/quality-metrics.json` is **blocked** — unless the violation
6
+ is already recorded in `.uap/quality-baseline.json` at an equal-or-worse value
7
+ (the ratchet: existing debt is frozen, new debt is refused).
8
+
9
+ Fast-path metrics computed here (stdlib-only mirror of src/quality/):
10
+ - LOC per file (maxLocPerFile, default 500)
11
+ - cyclomatic complexity (maxCyclomatic, default 22) — heuristic fallback
12
+ - cognitive complexity (maxCognitive, default 22) — heuristic fallback
13
+ - explicit any/unknown (maxAnyTypes, default 0) — TS/JS/Py
14
+
15
+ Slow metrics (coverage, CRAP, Halstead, duplication, dead code, mutation) need
16
+ external data and are enforced by `uap quality check` at commit/CI time, not
17
+ per edit.
18
+
19
+ The gate is INACTIVE (fails open) when `.uap/quality-metrics.json` is absent —
20
+ quality policing is opt-in per project via `uap quality init`. Escape hatch:
21
+ UAP_QUALITY_GATE_OFF=1. Mirrors src/quality/{scanner,complexity,baseline}.ts.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ sys.path.insert(0, str(Path(__file__).parent))
32
+ from _common import emit, parse_cli, repo_root, worktree_root # noqa: E402
33
+
34
+ PATH_WRITE_OPS = {"Write", "Edit", "MultiEdit", "write", "edit", "multiedit"}
35
+
36
+ EXT_LANG = {
37
+ ".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
38
+ ".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
39
+ ".py": "python", ".java": "java", ".cs": "csharp",
40
+ ".cpp": "cpp", ".cc": "cpp", ".cxx": "cpp", ".hpp": "cpp", ".hxx": "cpp",
41
+ ".c": "c", ".h": "c", ".rs": "rust", ".go": "go", ".rb": "ruby",
42
+ ".php": "php", ".swift": "swift", ".kt": "kotlin", ".kts": "kotlin", ".scala": "scala",
43
+ }
44
+
45
+ C_DECISION_WORDS = ("if", "for", "while", "case", "catch", "elif")
46
+ C_DECISION_OPS = ("&&", "||", "?")
47
+ PY_DECISION_WORDS = ("if", "elif", "for", "while", "except", "with", "assert")
48
+ PY_DECISION_OPS = ("and", "or")
49
+
50
+ # Boolean/ternary ops get a flat +1 cognitive (no nesting bump). Keep in
51
+ # lockstep with BOOL_OPS in src/quality/complexity.ts.
52
+ _BOOL_OPS = frozenset({"&&", "||", "?", "and", "or"})
53
+
54
+ RE_FUNC_KW = re.compile(r"\b(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(")
55
+ RE_NAMED_KW = re.compile(r"\b(?:def|fn|func|fun)\s+([A-Za-z_]\w*)\s*\(")
56
+ # Arrow functions assigned to consts — pervasive in modern TS; missing them
57
+ # dumps their bodies into the <module> score.
58
+ RE_ARROW = re.compile(
59
+ r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::\s*[^=]+?)?="
60
+ r"\s*(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>\s*\{"
61
+ )
62
+ RE_METHOD = re.compile(
63
+ r"^\s*(?:(?:public|private|protected|static|final|abstract|override|readonly|async|"
64
+ r"inline|virtual|constexpr|pub|unsafe|extern|internal|sealed|partial|suspend|open|"
65
+ r"export|default|get|set)\s+)*([A-Za-z_$][\w$]*)\s*\([^;{}]*\)\s*(?::\s*[^{;]+?)?\s*\{"
66
+ )
67
+ CONTROL_WORDS = {
68
+ "if", "for", "while", "switch", "catch", "return", "do", "else", "case",
69
+ "with", "synchronized", "lock", "using", "foreach", "when", "unless", "until",
70
+ }
71
+ RE_PY_DEF = re.compile(r"^(\s*)(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(")
72
+
73
+ ANY_PATTERNS = {
74
+ "typescript": re.compile(r":\s*any\b|:\s*unknown\b|\bas\s+any\b|<any>|<unknown>|Array<\s*any\s*>"),
75
+ "javascript": re.compile(r"/\*\s*@type\s*\{[^}]*\b(?:any|unknown)\b[^}]*\}\s*\*/"),
76
+ "python": re.compile(r":\s*Any\b|->\s*Any\b|cast\(\s*Any\b"),
77
+ }
78
+
79
+ TOKEN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*|&&|\|\||[?{}]")
80
+
81
+
82
+ def _strip_noise(content: str, comment_style: str) -> str:
83
+ """Remove strings and comments (state machine, mirrors languages.ts)."""
84
+ out: list[str] = []
85
+ i, n = 0, len(content)
86
+ state = "code"
87
+ while i < n:
88
+ ch = content[i]
89
+ nxt = content[i + 1] if i + 1 < n else ""
90
+ if state == "code":
91
+ if ch == "/" and nxt == "/":
92
+ state = "line"; i += 2; continue
93
+ if ch == "/" and nxt == "*":
94
+ state = "block"; i += 2; continue
95
+ if comment_style == "hash" and ch == "#":
96
+ state = "line"; i += 1; continue
97
+ if ch in ("'", '"', "`"):
98
+ state = ch; out.append(" "); i += 1; continue
99
+ out.append(ch); i += 1; continue
100
+ if state == "line":
101
+ if ch == "\n":
102
+ state = "code"; out.append("\n")
103
+ i += 1; continue
104
+ if state == "block":
105
+ if ch == "*" and nxt == "/":
106
+ state = "code"; i += 2; continue
107
+ if ch == "\n":
108
+ out.append("\n")
109
+ i += 1; continue
110
+ # inside a string literal
111
+ if ch == "\\":
112
+ i += 2; continue
113
+ if ch == state:
114
+ state = "code"; i += 1; continue
115
+ if ch == "\n":
116
+ out.append("\n")
117
+ if state != "`":
118
+ state = "code"
119
+ i += 1
120
+ return "".join(out)
121
+
122
+
123
+ def _function_header(line: str) -> str | None:
124
+ m = RE_FUNC_KW.search(line) or RE_NAMED_KW.search(line) or RE_ARROW.match(line)
125
+ if m:
126
+ return m.group(1)
127
+ m = RE_METHOD.match(line)
128
+ if m and m.group(1) not in CONTROL_WORDS:
129
+ return m.group(1)
130
+ return None
131
+
132
+
133
+ def _count_decisions(segment: str, words: tuple[str, ...], ops: tuple[str, ...],
134
+ initial_depth: int = 0) -> tuple[int, int]:
135
+ cyclomatic = 1
136
+ cognitive = 0
137
+ depth = initial_depth
138
+ # `??` counts as ONE decision, `?.` as none — keep in lockstep with
139
+ # src/quality/complexity.ts (countDecisions).
140
+ segment = segment.replace("??", " ? ").replace("?.", ".")
141
+ for m in TOKEN_RE.finditer(segment):
142
+ tok = m.group(0)
143
+ if tok == "{":
144
+ depth += 1
145
+ continue
146
+ if tok == "}":
147
+ depth = max(0, depth - 1)
148
+ continue
149
+ if tok in words or tok in ops:
150
+ cyclomatic += 1
151
+ cognitive += 1 if tok in _BOOL_OPS else 1 + max(0, depth)
152
+ return cyclomatic, cognitive
153
+
154
+
155
+ def _analyze(content: str, lang: str) -> tuple[list[dict], int]:
156
+ """Returns (functions, loc). functions: [{name,line,cyclomatic,cognitive}]."""
157
+ style = "hash" if lang in ("python", "ruby") else "c"
158
+ indent_based = lang == "python"
159
+ words = PY_DECISION_WORDS if lang == "python" else C_DECISION_WORDS
160
+ ops = PY_DECISION_OPS if lang == "python" else C_DECISION_OPS
161
+
162
+ stripped = _strip_noise(content, style)
163
+ lines = stripped.split("\n")
164
+ loc = sum(1 for ln in lines if ln.strip())
165
+ functions: list[dict] = []
166
+
167
+ if indent_based:
168
+ open_fn: dict | None = None
169
+ buf: list[str] = []
170
+ mod_buf: list[str] = []
171
+
172
+ def flush() -> None:
173
+ nonlocal open_fn, buf
174
+ if open_fn is None:
175
+ return
176
+ seg = "\n".join(buf)
177
+ cc, cog = _count_decisions(seg, words, ops)
178
+ functions.append({"name": open_fn["name"], "line": open_fn["line"],
179
+ "cyclomatic": cc, "cognitive": cog})
180
+ open_fn = None
181
+ buf = []
182
+
183
+ for idx, line in enumerate(lines):
184
+ dm = RE_PY_DEF.match(line)
185
+ if dm:
186
+ flush()
187
+ open_fn = {"name": dm.group(2), "line": idx + 1, "indent": len(dm.group(1))}
188
+ buf.append(line)
189
+ continue
190
+ if open_fn is not None:
191
+ indent = len(line) - len(line.lstrip())
192
+ if line.strip() and indent <= open_fn["indent"]:
193
+ flush()
194
+ mod_buf.append(line) # the dedent line is module-level
195
+ else:
196
+ buf.append(line)
197
+ else:
198
+ mod_buf.append(line)
199
+ flush()
200
+ # Module-level decisions score as a pseudo-function, mirroring
201
+ # src/quality/complexity.ts — signature parity is load-bearing.
202
+ mcc, mcog = _count_decisions("\n".join(mod_buf), words, ops)
203
+ if mcc > 1:
204
+ functions.append({"name": "<module>", "line": 1,
205
+ "cyclomatic": mcc, "cognitive": mcog})
206
+ return functions, loc
207
+
208
+ depth = 0
209
+ open_fn = None
210
+ buf = []
211
+ mod_buf = []
212
+ for idx, line in enumerate(lines):
213
+ if open_fn is None:
214
+ name = _function_header(line)
215
+ if name:
216
+ delta = line.count("{") - line.count("}")
217
+ if delta <= 0:
218
+ if "{" not in line:
219
+ # Multi-line signature — the opening brace is on a
220
+ # later line. Open the region now, anticipating the
221
+ # body one level above current depth.
222
+ open_fn = {"name": name, "line": idx + 1,
223
+ "start_depth": depth + 1, "entered": False}
224
+ buf = [line]
225
+ continue
226
+ # One-line function: the brace balance never dips below
227
+ # start_depth, so without this branch the region stays
228
+ # open and swallows the REST OF THE FILE into this
229
+ # function's metrics. Score the header line alone.
230
+ cc, cog = _count_decisions(line, words, ops, 0)
231
+ functions.append({"name": name, "line": idx + 1,
232
+ "cyclomatic": cc, "cognitive": cog})
233
+ depth = max(0, depth + delta)
234
+ continue
235
+ open_fn = {
236
+ "name": name,
237
+ "line": idx + 1,
238
+ "start_depth": depth + delta,
239
+ "entered": True,
240
+ }
241
+ buf = [line]
242
+ depth += delta
243
+ continue
244
+ mod_buf.append(line)
245
+ depth += line.count("{") - line.count("}")
246
+ if depth < 0:
247
+ depth = 0
248
+ continue
249
+ buf.append(line)
250
+ d = line.count("{") - line.count("}")
251
+ depth += d
252
+ if not open_fn["entered"]:
253
+ if d > 0:
254
+ open_fn["entered"] = True
255
+ continue # never close-check before the body opens
256
+ if depth < open_fn["start_depth"]:
257
+ seg = "\n".join(buf)
258
+ cc, cog = _count_decisions(seg, words, ops, -1)
259
+ functions.append({"name": open_fn["name"], "line": open_fn["line"],
260
+ "cyclomatic": cc, "cognitive": cog})
261
+ open_fn = None
262
+ buf = []
263
+ if open_fn is not None:
264
+ seg = "\n".join(buf)
265
+ cc, cog = _count_decisions(seg, words, ops, -1)
266
+ functions.append({"name": open_fn["name"], "line": open_fn["line"],
267
+ "cyclomatic": cc, "cognitive": cog})
268
+ mcc, mcog = _count_decisions("\n".join(mod_buf), words, ops)
269
+ if mcc > 1:
270
+ functions.append({"name": "<module>", "line": 1,
271
+ "cyclomatic": mcc, "cognitive": mcog})
272
+ return functions, loc
273
+
274
+
275
+ def _count_any(content: str, lang: str) -> int:
276
+ pat = ANY_PATTERNS.get(lang)
277
+ if not pat:
278
+ return 0
279
+ style = "hash" if lang in ("python", "ruby") else "c"
280
+ stripped = _strip_noise(content, style)
281
+ return sum(len(pat.findall(line)) for line in stripped.split("\n"))
282
+
283
+
284
+ def _violations(rel_path: str, content: str, thresholds: dict) -> list[dict]:
285
+ lang = EXT_LANG.get(Path(rel_path).suffix.lower())
286
+ if not lang:
287
+ return []
288
+ out: list[dict] = []
289
+ functions, loc = _analyze(content, lang)
290
+
291
+ max_loc = thresholds.get("maxLocPerFile", 500)
292
+ if loc > max_loc:
293
+ out.append({"signature": f"{rel_path}::locPerFile::<file>", "value": loc,
294
+ "message": f"{rel_path}: {loc} LOC exceeds max {max_loc}"})
295
+
296
+ max_cc = thresholds.get("maxCyclomatic", 22)
297
+ max_cog = thresholds.get("maxCognitive", 22)
298
+ for fn in functions:
299
+ if fn["cyclomatic"] > max_cc:
300
+ out.append({"signature": f"{rel_path}::cyclomatic::{fn['name']}@{fn['line']}",
301
+ "value": fn["cyclomatic"],
302
+ "message": f"{rel_path}:{fn['line']} {fn['name']}() cyclomatic {fn['cyclomatic']} exceeds max {max_cc}"})
303
+ if fn["cognitive"] > max_cog:
304
+ out.append({"signature": f"{rel_path}::cognitive::{fn['name']}@{fn['line']}",
305
+ "value": fn["cognitive"],
306
+ "message": f"{rel_path}:{fn['line']} {fn['name']}() cognitive {fn['cognitive']} exceeds max {max_cog}"})
307
+
308
+ max_any = thresholds.get("maxAnyTypes", 0)
309
+ n_any = _count_any(content, lang)
310
+ if n_any > max_any:
311
+ out.append({"signature": f"{rel_path}::anyTypes::<file>", "value": n_any,
312
+ "message": f"{rel_path}: {n_any} explicit any/unknown usage(s) exceed max {max_any}"})
313
+ return out
314
+
315
+
316
+ def _load_json(root: Path, name: str) -> dict | None:
317
+ for base in (worktree_root(), root):
318
+ p = base / ".uap" / name
319
+ if p.exists():
320
+ try:
321
+ return json.loads(p.read_text())
322
+ except Exception: # noqa: BLE001
323
+ return None
324
+ return None
325
+
326
+
327
+ def _target_path(args: dict) -> str:
328
+ for key in ("file_path", "path", "notebook_path", "filePath", "target"):
329
+ if args.get(key):
330
+ return str(args[key])
331
+ return ""
332
+
333
+
334
+ def _post_edit_content(op: str, args: dict, target: str) -> str:
335
+ """Best reconstruction of the file AFTER the edit.
336
+
337
+ Write: the proposed content. Edit: old_string replaced by new_string in
338
+ the on-disk file (first occurrence). MultiEdit: all edits applied in
339
+ order. Falls back to the inserted text when reconstruction is impossible —
340
+ partial scans still catch over-threshold inserts.
341
+ """
342
+ if args.get("content") is not None:
343
+ return str(args["content"])
344
+ disk = ""
345
+ if target:
346
+ try:
347
+ disk = Path(target).read_text()
348
+ except OSError:
349
+ disk = ""
350
+ edits = args.get("edits")
351
+ if isinstance(edits, list):
352
+ out = disk
353
+ for e in edits:
354
+ if not isinstance(e, dict):
355
+ continue
356
+ old, new = str(e.get("old_string", "")), str(e.get("new_string", ""))
357
+ out = out.replace(old, new, 1) if old and old in out else out + "\n" + new
358
+ return out
359
+ if args.get("new_string") is not None:
360
+ new = str(args["new_string"])
361
+ old = str(args.get("old_string", ""))
362
+ if disk and old and old in disk:
363
+ return disk.replace(old, new, 1)
364
+ return disk + "\n" + new if disk else new
365
+ return ""
366
+
367
+
368
+ def _block_message(blocking: list[dict]) -> str:
369
+ lines = ["quality-metrics gate: this edit introduces NEW or WORSENED metric debt:"]
370
+ for v in blocking[:6]:
371
+ lines.append(f" ✗ {v['message']}")
372
+ if len(blocking) > 6:
373
+ lines.append(f" … and {len(blocking) - 6} more")
374
+ lines.append(" Fix the violation, keep it at/below the baselined value, or regenerate the")
375
+ lines.append(" baseline deliberately: uap quality baseline --update (reviewable in git).")
376
+ lines.append(" Full metrics (coverage/CRAP/duplication/mutation): uap quality check.")
377
+ lines.append(" Escape hatch: UAP_QUALITY_GATE_OFF=1")
378
+ return "\n".join(lines)
379
+
380
+
381
+ def _rel_path(target: str, root: Path) -> str:
382
+ try:
383
+ rel = str(Path(target).resolve().relative_to(root.resolve()))
384
+ except (ValueError, OSError):
385
+ rel = target
386
+ return rel.replace("\\", "/")
387
+
388
+
389
+ def _in_scope(rel: str, config: dict) -> str | None:
390
+ """Honour the config's own scope, or the enforcer is STRICTER than the CLI
391
+ (e.g. blocking a Write into dist/ output that `uap quality check`
392
+ ignores). Mirrors isScannableSource in src/quality/scanner.ts. Returns a
393
+ skip reason, or None when the file is in scope."""
394
+ source_exts = config.get("sourceExts")
395
+ if isinstance(source_exts, list) and Path(rel).suffix.lower() not in source_exts:
396
+ return "extension not in quality-metrics.json sourceExts"
397
+ for excl in config.get("excludeDirs", []):
398
+ if f"/{excl}/" in f"/{rel}" or rel.startswith(f"{excl}/"):
399
+ return f"excluded dir ({excl})"
400
+ return None
401
+
402
+
403
+ def main() -> None:
404
+ op, args = parse_cli()
405
+
406
+ if os.environ.get("UAP_QUALITY_GATE_OFF") == "1":
407
+ emit(True, "UAP_QUALITY_GATE_OFF override set")
408
+ if op not in PATH_WRITE_OPS:
409
+ emit(True, "not a file-write operation")
410
+
411
+ target = _target_path(args)
412
+ lang = EXT_LANG.get(Path(target).suffix.lower())
413
+ if not lang:
414
+ emit(True, "not a scannable source file")
415
+
416
+ config = _load_json(repo_root(), "quality-metrics.json")
417
+ if not config:
418
+ emit(True, "no .uap/quality-metrics.json — quality gate inactive")
419
+ thresholds = config.get("thresholds", {})
420
+
421
+ content = _post_edit_content(op, args, target)
422
+ if not content:
423
+ emit(True, "no post-edit content to scan")
424
+
425
+ rel = _rel_path(target, worktree_root())
426
+ skip = _in_scope(rel, config)
427
+ if skip:
428
+ emit(True, skip)
429
+
430
+ violations = _violations(rel, content, thresholds)
431
+ if not violations:
432
+ emit(True, "within quality thresholds")
433
+
434
+ baseline = _load_json(repo_root(), "quality-baseline.json") or {}
435
+ entries = {e.get("signature"): e.get("value", 0)
436
+ for e in baseline.get("entries", []) if isinstance(e, dict)}
437
+
438
+ blocking = [v for v in violations
439
+ if v["signature"] not in entries or v["value"] > entries[v["signature"]]]
440
+ if not blocking:
441
+ emit(True, f"{len(violations)} violation(s) grandfathered by the quality baseline")
442
+
443
+ emit(False, _block_message(blocking))
444
+
445
+
446
+ if __name__ == "__main__":
447
+ main()
File without changes
File without changes
File without changes
File without changes
@@ -39,7 +39,8 @@ like `feature/foo` and `feature-foo` never collide on one artifact. Recognised
39
39
  shape:
40
40
 
41
41
  ```json
42
- { "branch": "<name>", "head": "<sha>", "verdict": "approve", "reviewers": ["code-quality-reviewer", "security-code-reviewer", "..."] }
42
+ { "branch": "<name>", "head": "<sha>", "verdict": "approve", "reviewers": ["code-quality-reviewer", "security-code-reviewer", "..."],
43
+ "quality": { "pass": true, "blocking": 0, "report": ".uap/quality-report.json" } }
43
44
  ```
44
45
 
45
46
  If the artifact records a `branch` that differs from the current branch, or a
@@ -48,6 +49,13 @@ stale) and the op is blocked until a fresh review is recorded. Including
48
49
  `branch` and `head` is strongly recommended so the artifact unambiguously
49
50
  identifies what it covers.
50
51
 
52
+ The optional `quality` block fuses the **quality-metrics gate** into the
53
+ review: when the artifact carries `"quality": {"pass": false, ...}` (written
54
+ from `uap quality check --json`), the ship is blocked until the machine
55
+ metrics pass — reviewers adjudicate the numbers, they cannot outvote them.
56
+ An absent or `null` quality block fails open (gate inactive / older
57
+ artifacts).
58
+
51
59
  ## Why
52
60
 
53
61
  The `parallel-expert-review` skill (and the `architect-reviewer` droid) claim
@@ -0,0 +1,74 @@
1
+ # quality-metrics-gate
2
+
3
+ **Category**: quality
4
+ **Level**: REQUIRED
5
+ **Enforcement Stage**: pre-exec
6
+ **Tags**: quality, complexity, coverage, mutation, duplication, metrics
7
+
8
+ ## Rule
9
+
10
+ A `Write`, `Edit`, or `MultiEdit` to a source file whose post-edit content
11
+ violates the thresholds in `.uap/quality-metrics.json` is **blocked** unless
12
+ the violation is already recorded in `.uap/quality-baseline.json` at an
13
+ equal-or-worse value. This is a **ratchet**: existing debt is frozen in the
14
+ baseline, new or worsened debt is refused.
15
+
16
+ Default thresholds (configurable per project):
17
+
18
+ - Cyclomatic complexity < 22 per function
19
+ - Cognitive complexity < 22 per function
20
+ - Halstead difficulty < 80 per function (via rust-code-analysis, when installed)
21
+ - Lines of code < 500 per file
22
+ - Test coverage 100% (via coverage-summary.json, at commit/CI time)
23
+ - CRAP score < 25 per function (cc²·(1−cov)³ + cc)
24
+ - Surviving mutants: 0 (Stryker incremental on changed files, via `uap quality mutate`)
25
+ - Dead code: 0 (knip / vulture, when installed)
26
+ - Redundant code: 0 (jscpd, when installed)
27
+ - Explicit `any`/`unknown` types: 0
28
+
29
+ The gate is **inactive** (fails open) when `.uap/quality-metrics.json` is
30
+ absent — policing is opt-in per project via `uap quality init`. Languages
31
+ without installed tooling are skipped with a logged warning; the built-in
32
+ heuristic scanner (LOC, cyclomatic, cognitive, any-types) always runs for
33
+ TS/JS/Py/Java/C#/C/C++/Rust/Go and similar brace or indent languages.
34
+
35
+ ## Why
36
+
37
+ Quality metrics only police outcomes when enforcement is deterministic and
38
+ immediate. An LLM reviewer asked "is cognitive complexity < 22?" estimates;
39
+ a gate that computes the number at edit time does not. The baseline ratchet
40
+ makes the strict thresholds adoptable on real codebases: day one, the gate
41
+ enforces "never regress"; the baseline then only shrinks as debt is paid
42
+ down. Judgment calls (waivers, gaming detection — assertion-free tests,
43
+ complexity split into worse trivial functions) belong to the parallel expert
44
+ review, which consumes the machine report via the `quality` block of the
45
+ review artifact.
46
+
47
+ ## Enforcement
48
+
49
+ Python enforcer `quality_metrics_gate.py` (stdlib-only) computes the fast-path metrics
50
+ (LOC, cyclomatic, cognitive, any-types) on the reconstructed post-edit content
51
+ and ratchets against `.uap/quality-baseline.json`. It mirrors
52
+ `src/quality/{scanner,complexity,baseline}.ts` — a parity test
53
+ (`test/quality/quality-gate-enforcer.test.ts`) locks the violation signatures
54
+ the two implementations produce, since the CLI-written baseline is consumed by
55
+ the enforcer.
56
+
57
+ Slow metrics (coverage, CRAP, Halstead, duplication, dead code, mutation) are
58
+ enforced by `uap quality check` at commit/CI time, not per edit.
59
+
60
+ Escape hatch: `UAP_QUALITY_GATE_OFF=1`. Deliberate debt acceptance:
61
+ `uap quality baseline --update` (committed, reviewable in git).
62
+
63
+ **Scanner upgrade caveat:** a baseline generated with the built-in heuristic
64
+ scanner uses heuristic signatures (`name@line`). Installing lizard or
65
+ rust-code-analysis later re-identifies complexity violations with
66
+ authoritative names/offsets, so previously grandfathered entries may block
67
+ once after the upgrade. Regenerate the baseline (`uap quality baseline
68
+ --update`) when adopting an external scanner.
69
+
70
+ ```rules
71
+ - title: "New or worsened metric debt is blocked; the baseline only shrinks"
72
+ keywords: [complexity, cyclomatic, cognitive, halstead, coverage, CRAP, mutation, duplication, dead code, any type, quality gate, ratchet]
73
+ antiPatterns: ["function over complexity threshold", "file over 500 LOC", "explicit any", "untested new code", "baseline regeneration without review"]
74
+ ```
@@ -2,7 +2,7 @@
2
2
  <!-- Optimizations #22-27: Template Compression, Structured Iteration, Inline Domain Knowledge, Early Impossibility Exit, Conditional Context, Remove Emphasis Theater -->
3
3
  <!-- Optimizations #28-34: Mandatory Verifier Loop, Decoder-First Gate, Context Stripping, Environment Check, Schema Diff, State Protection, Conditional Domain -->
4
4
 
5
- <!-- ENFORCEMENT_CHECKS: SESSION_START,DECISION_LOOP,MANDATORY_WORKTREE,PARALLEL_REVIEW,SCHEMA_DIFF,GATES,RTK_INCLUDES,PATTERN_ROUTER,VALIDATE_PLAN -->
5
+ <!-- ENFORCEMENT_CHECKS: SESSION_START,DECISION_LOOP,MANDATORY_WORKTREE,PARALLEL_REVIEW,SCHEMA_DIFF,GATES,HOOK_INCLUDES,PATTERN_ROUTER,VALIDATE_PLAN -->
6
6
  <!-- TEMPLATE_VERSION: 2.3.0 -->
7
7
  <!-- LAST_VALIDATED: 2026-03-09 -->
8
8
 
@@ -22,6 +22,49 @@ if [ -z "$CMD" ]; then
22
22
  exit 0
23
23
  fi
24
24
 
25
+ # ─── Worktree Enforcement Opt-Out ───────────────────────────────
26
+ # The worktree guards below (direct master commit/push, destructive git
27
+ # outside worktrees) only apply when the project mandates worktrees. Setup
28
+ # wizard "Worktree isolation" off → .uap.json worktrees.enforce=false and the
29
+ # guards stand down. Unreadable/missing config = enforce (backward
30
+ # compatible). UAP_NO_WORKTREE=1 is the per-run escape hatch for the
31
+ # commit/push guards (the destructive-git guard keys on the config only — it
32
+ # is data-loss prevention, not a workflow mandate).
33
+ # (The protocol-tag, infra-protect, IaC, force-push, and version-edit guards
34
+ # above/below are independent of the worktree workflow and stay on.)
35
+ #
36
+ # The config lives at the MAIN checkout root and is typically gitignored, so
37
+ # worktree checkouts don't carry it: resolve the main root via git-common-dir
38
+ # (a linked worktree's toplevel would miss the file and silently re-arm).
39
+ # Probe only for git commands — non-git payloads skip the python3 spawn.
40
+ _WT_ENFORCE="on"
41
+ _WT_CFG="on"
42
+ if echo "$CMD" | grep -qE '\bgit\b'; then
43
+ _WT_CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
44
+ _WT_COMMON="$(git -C "${_WT_CWD:-.}" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
45
+ _WT_ROOT=""
46
+ if [ -n "$_WT_COMMON" ]; then
47
+ _WT_ROOT="$(dirname "$_WT_COMMON" 2>/dev/null || true)"
48
+ fi
49
+ if [ -z "$_WT_ROOT" ] || [ ! -d "$_WT_ROOT" ]; then
50
+ _WT_ROOT="$(cd "$HOOK_DIR/../.." 2>/dev/null && pwd || true)"
51
+ fi
52
+ if [ -n "$_WT_ROOT" ]; then
53
+ _WT_CFG="$(python3 -c '
54
+ import json, sys
55
+ try:
56
+ cfg = json.load(open(sys.argv[1]))
57
+ print("off" if (cfg.get("worktrees") or {}).get("enforce") is False else "on")
58
+ except Exception:
59
+ print("on")
60
+ ' "$_WT_ROOT/.uap.json" 2>/dev/null || echo on)"
61
+ _WT_ENFORCE="$_WT_CFG"
62
+ fi
63
+ fi
64
+ if [ "${UAP_NO_WORKTREE:-0}" = "1" ]; then
65
+ _WT_ENFORCE="off"
66
+ fi
67
+
25
68
  # ─── Protocol Tag Injection Guard ────────────────────────────────
26
69
  # Reject Bash payloads that still contain standalone protocol tag lines.
27
70
  # These fragments can appear after malformed tool-call rendering and must
@@ -78,7 +121,8 @@ fi
78
121
 
79
122
  # ─── Direct Master/Main Commit Protection ───────────────────────
80
123
  # Block git commit when on master/main AND not inside a worktree
81
- if echo "$CMD" | grep -qE '\bgit\s+commit\b'; then
124
+ # (skipped when the project opted out of worktree enforcement)
125
+ if [ "$_WT_ENFORCE" != "off" ] && echo "$CMD" | grep -qE '\bgit\s+commit\b'; then
82
126
  PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
83
127
  CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
84
128
  CHECK_DIR="${CWD:-$PROJECT_DIR}"
@@ -99,7 +143,8 @@ fi
99
143
 
100
144
  # ─── Direct Push to Master/Main Protection ──────────────────────
101
145
  # Block git push targeting main/master directly (not through PR)
102
- if echo "$CMD" | grep -qE '\bgit\s+push\b'; then
146
+ # (skipped when the project opted out of worktree enforcement)
147
+ if [ "$_WT_ENFORCE" != "off" ] && echo "$CMD" | grep -qE '\bgit\s+push\b'; then
103
148
  # Block explicit pushes to main/master
104
149
  if echo "$CMD" | grep -qE '\bgit\s+push\s+(origin\s+)?(main|master)\b'; then
105
150
  # Allow push after version bump (git push && git push --tags pattern)
@@ -113,7 +158,9 @@ fi
113
158
 
114
159
  # ─── Destructive Git Operations ─────────────────────────────────
115
160
  # Block git reset --hard and git clean -f outside worktrees
116
- if echo "$CMD" | grep -qE '\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[a-z]*f'; then
161
+ # (keys on the project config only — data-loss prevention, not a per-run
162
+ # workflow choice, so UAP_NO_WORKTREE does not stand it down)
163
+ if [ "$_WT_CFG" != "off" ] && echo "$CMD" | grep -qE '\bgit\s+reset\s+--hard\b|\bgit\s+clean\s+-[a-z]*f'; then
117
164
  CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)
118
165
  if ! echo "${CWD:-.}" | grep -q '\.worktrees/'; then
119
166
  echo "BLOCKED [git-safety]: Destructive git operations (reset --hard, clean -f) are prohibited outside worktrees. These can destroy uncommitted work in the project root." >&2