synapse-hub 2.2.2 → 2.2.4

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.
@@ -26,8 +26,10 @@ def distill(log_text, max_lines=40):
26
26
  # Noise filters
27
27
  noise_patterns = [
28
28
  re.compile(r'^\s*$'),
29
- re.compile(r'\[INFO\b'),
30
- re.compile(r'\[DEBUG\b'),
29
+ re.compile(r'\[INFO\b|\bINFO:|\bINFO\s+', re.IGNORECASE),
30
+ re.compile(r'\[DEBUG\b|\bDEBUG:|\bDEBUG\s+', re.IGNORECASE),
31
+ re.compile(r'\[TRACE\b|\bTRACE:|\bTRACE\s+', re.IGNORECASE),
32
+ re.compile(r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}.*\b(?:INFO|DEBUG|TRACE)\b', re.IGNORECASE),
31
33
  re.compile(r'\bPASSED\b', re.IGNORECASE),
32
34
  re.compile(r'> Task :'),
33
35
  re.compile(r'SLF4J:'),
@@ -47,10 +49,11 @@ def distill(log_text, max_lines=40):
47
49
  re.compile(r'Caused by:'),
48
50
  re.compile(r'at (?:Enterprise\.|Script\d+|com\.kms)'),
49
51
  re.compile(r'TC-\d+.*FAILED'),
52
+ re.compile(r'FAIL\s+.*(?:\.spec|\.test)'),
50
53
  ]
51
54
 
52
- tc_pattern = re.compile(r'(TC-\d+)')
53
- line_num_pattern = re.compile(r'((?:Script\d+|\w+Page)\.groovy:(\d+))')
55
+ tc_pattern = re.compile(r'(TC-\d+|test_[A-Za-z0-9_]+|[A-Za-z0-9_-]+\.(?:spec|test)\.[jt]sx?)')
56
+ line_num_pattern = re.compile(r'((?:Script\d+|\w+Page|\w+\.(?:groovy|ts|js|py)):(\d+))|File "([^"]+)", line (\d+)')
54
57
  exception_pattern = re.compile(r'([A-Za-z0-9_.]*(?:Exception|Error)):?\s*(.*)')
55
58
 
56
59
  captured_failures = []
@@ -63,7 +66,7 @@ def distill(log_text, max_lines=40):
63
66
  for line in lines:
64
67
  # Check if line indicates failure
65
68
  is_fail_line = any(p.search(line) for p in failure_indicators)
66
- is_noise = any(p.search(line) for p in noise_patterns)
69
+ is_noise = any(p.search(line) for p in noise_patterns) and not is_fail_line
67
70
 
68
71
  # Detect TC identifier
69
72
  m_tc = tc_pattern.search(line)
@@ -73,7 +76,7 @@ def distill(log_text, max_lines=40):
73
76
  # Detect script line number
74
77
  m_loc = line_num_pattern.search(line)
75
78
  if m_loc:
76
- failing_locs.append(m_loc.group(1))
79
+ failing_locs.append(m_loc.group(1) or f"{m_loc.group(3)}:{m_loc.group(4)}")
77
80
 
78
81
  if is_fail_line:
79
82
  in_failure_block = True
@@ -102,9 +105,12 @@ def distill(log_text, max_lines=40):
102
105
  output.append("======================================================================")
103
106
  return "\n".join(output)
104
107
 
105
- output.append(f"❌ FAILURES DETECTED: {len(failing_tcs)} Test Case(s) marked FAILED.")
106
108
  if failing_tcs:
109
+ output.append(f"❌ FAILURES DETECTED: {len(failing_tcs)} Test Case(s) marked FAILED.")
107
110
  output.append(f" Targeted TCs: {', '.join(sorted(failing_tcs))}")
111
+ else:
112
+ output.append(f"❌ FAILURES / ERRORS DETECTED: {len(captured_failures)} failure block(s) isolated.")
113
+
108
114
  if failing_locs:
109
115
  unique_locs = sorted(set(failing_locs))
110
116
  output.append(f" Failing Script Location(s): {', '.join(unique_locs[:5])}")
@@ -116,10 +122,13 @@ def distill(log_text, max_lines=40):
116
122
  output.append("======================================================================")
117
123
  return "\n".join(output)
118
124
 
125
+ # Public alias for MCP server compatibility
126
+ distill_log_content = distill
127
+
119
128
  def main():
120
129
  parser = argparse.ArgumentParser(description="Distill terminal execution logs into high-signal failure blocks.")
121
130
  parser.add_argument("--file", "-f", help="Path to raw log file (reads from stdin if omitted).")
122
- parser.add_argument("--save-raw", "-s", default="Reports/raw_execution.log", help="Path to preserve raw log.")
131
+ parser.add_argument("--save-raw", "-s", default=None, help="Path to preserve raw log (omitted by default to prevent hub pollution).")
123
132
  args = parser.parse_args()
124
133
 
125
134
  if args.file:
@@ -134,13 +143,13 @@ def main():
134
143
  sys.exit(0)
135
144
  log_text = sys.stdin.read()
136
145
 
137
- # Preserve raw log on disk
146
+ # Preserve raw log on disk if explicitly requested
138
147
  if args.save_raw:
139
148
  try:
140
149
  os.makedirs(os.path.dirname(os.path.abspath(args.save_raw)), exist_ok=True)
141
150
  with open(args.save_raw, "w", encoding="utf-8") as fp:
142
151
  fp.write(log_text)
143
- except Exception as e:
152
+ except Exception:
144
153
  pass
145
154
 
146
155
  summary = distill(log_text)
@@ -36,6 +36,7 @@ Usage:
36
36
  import os
37
37
  import sys
38
38
  import json
39
+ import re
39
40
  import uuid
40
41
  import csv
41
42
  import argparse
@@ -82,7 +83,7 @@ def build_test_script(
82
83
  "// Auto-generated Test Assertions by Synapse-Hub",
83
84
  f"// 1. Assert Status Code is {expected_status}",
84
85
  f"pm.test('Status code is {expected_status}', function () {{",
85
- f" pm.expect(pm.response.code).to.be.oneOf([{expected_status}, 200, 201]);",
86
+ f" pm.expect(pm.response.code).to.eql({expected_status});",
86
87
  "});",
87
88
  "",
88
89
  f"// 2. Assert SLA Latency (< {max_time_ms}ms)",
@@ -280,13 +281,34 @@ def parse_openapi_spec(spec_data: Dict[str, Any], base_url_override: Optional[st
280
281
  "options": {"raw": {"language": "json"}}
281
282
  }
282
283
 
283
- # URL decomposition
284
- path_segments = [seg for seg in path.strip("/").split("/") if seg]
284
+ # URL decomposition and path parameterization
285
+ raw_path = path
286
+ path_variables = []
287
+ normalized_segments = []
288
+ for seg in path.strip("/").split("/"):
289
+ if not seg:
290
+ continue
291
+ match = re.match(r"^\{([a-zA-Z0-9_-]+)\}$", seg)
292
+ if match:
293
+ param_name = match.group(1)
294
+ raw_path = raw_path.replace(f"{{{param_name}}}", f":{param_name}")
295
+ normalized_segments.append(f":{param_name}")
296
+ default_val = "{{lastCreatedId}}" if param_name.lower() in ("id", "entityid", "orderid") else f"{{{{{param_name}}}}}"
297
+ path_variables.append({
298
+ "key": param_name,
299
+ "value": default_val,
300
+ "description": f"URL Path variable {param_name}"
301
+ })
302
+ else:
303
+ normalized_segments.append(seg)
304
+
285
305
  url_obj = {
286
- "raw": f"{{{{baseUrl}}}}{path}",
306
+ "raw": f"{{{{baseUrl}}}}{raw_path}",
287
307
  "host": ["{{baseUrl}}"],
288
- "path": path_segments
308
+ "path": normalized_segments
289
309
  }
310
+ if path_variables:
311
+ url_obj["variable"] = path_variables
290
312
  if query_params:
291
313
  url_obj["query"] = query_params
292
314
 
@@ -451,10 +473,10 @@ def generate_postman_environment(name: str = "Enterprise-Staging-Env", base_url:
451
473
  "name": name,
452
474
  "values": [
453
475
  {"key": "baseUrl", "value": base_url, "enabled": True},
454
- {"key": "bearerToken", "value": "", "enabled": True},
476
+ {"key": "bearerToken", "value": "", "type": "secret", "enabled": True},
455
477
  {"key": "lastCreatedId", "value": "1", "enabled": True},
456
478
  {"key": "adminUser", "value": "qa_admin@enterprise.local", "enabled": True},
457
- {"key": "adminPass", "value": "SecretVault#2026", "enabled": True}
479
+ {"key": "adminPass", "value": "SecretVault#2026", "type": "secret", "enabled": True}
458
480
  ],
459
481
  "_postman_variable_scope": "environment"
460
482
  }
@@ -122,6 +122,16 @@ def build_jmx_xml(
122
122
  f' <stringProp name="Argument.value">{sla_p95}</stringProp>',
123
123
  ' <stringProp name="Argument.metadata">=</stringProp>',
124
124
  ' </elementProp>',
125
+ ' <elementProp name="MAX_LATENCY" elementType="Argument">',
126
+ ' <stringProp name="Argument.name">MAX_LATENCY</stringProp>',
127
+ f' <stringProp name="Argument.value">${{__P(max_latency, {max(3000, sla_p95 * 2)})}}</stringProp>',
128
+ ' <stringProp name="Argument.metadata">=</stringProp>',
129
+ ' </elementProp>',
130
+ ' <elementProp name="LAST_CREATED_ID" elementType="Argument">',
131
+ ' <stringProp name="Argument.name">LAST_CREATED_ID</stringProp>',
132
+ ' <stringProp name="Argument.value">1</stringProp>',
133
+ ' <stringProp name="Argument.metadata">=</stringProp>',
134
+ ' </elementProp>',
125
135
  ' <elementProp name="BEARER_TOKEN" elementType="Argument">',
126
136
  ' <stringProp name="Argument.name">BEARER_TOKEN</stringProp>',
127
137
  ' <stringProp name="Argument.value"></stringProp>',
@@ -469,9 +479,9 @@ def build_jmx_xml(
469
479
  f'{indent} <intProp name="Assertion.test_type">40</intProp>',
470
480
  f'{indent} </ResponseAssertion>',
471
481
  f'{indent} <hashTree/>',
472
- f'{indent} <!-- SLA Latency Assertion -->',
473
- f'{indent} <DurationAssertion guiclass="DurationAssertionGui" testclass="DurationAssertion" testname="Assert SLA Latency (&lt; ${{SLA_P95}}ms)" enabled="true">',
474
- f'{indent} <stringProp name="DurationAssertion.duration">${{SLA_P95}}</stringProp>',
482
+ f'{indent} <!-- SLA Ceiling Assertion (Percentile P95 is validated post-run by jtl_analyzer.py) -->',
483
+ f'{indent} <DurationAssertion guiclass="DurationAssertionGui" testclass="DurationAssertion" testname="Assert Max Latency Ceiling (&lt; ${{MAX_LATENCY}}ms)" enabled="true">',
484
+ f'{indent} <stringProp name="DurationAssertion.duration">${{MAX_LATENCY}}</stringProp>',
475
485
  f'{indent} </DurationAssertion>',
476
486
  f'{indent} <hashTree/>'
477
487
  ])
@@ -492,7 +502,7 @@ def build_jmx_xml(
492
502
  f'{indent} <!-- Deep Correlation: Random Array Item Selection (Match No: 0) -->',
493
503
  f'{indent} <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Random Item Selector" enabled="true">',
494
504
  f'{indent} <stringProp name="JSONPostProcessor.referenceNames">RANDOM_ITEM_ID</stringProp>',
495
- f'{indent} <stringProp name="JSONPostProcessor.jsonPathExprs">$..items[?(@.stock &gt; 0)].id, $..id, $..code</stringProp>',
505
+ f'{indent} <stringProp name="JSONPostProcessor.jsonPathExprs">$..id</stringProp>',
496
506
  f'{indent} <stringProp name="JSONPostProcessor.match_numbers">0</stringProp>',
497
507
  f'{indent} <stringProp name="JSONPostProcessor.defaultValues">${{RANDOM_ITEM_ID}}</stringProp>',
498
508
  f'{indent} </JSONPostProcessor>',
@@ -505,7 +515,7 @@ def build_jmx_xml(
505
515
  f'{indent} <!-- Auto-Extract Dynamic Bearer Token -->',
506
516
  f'{indent} <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Bearer Token" enabled="true">',
507
517
  f'{indent} <stringProp name="JSONPostProcessor.referenceNames">BEARER_TOKEN</stringProp>',
508
- f'{indent} <stringProp name="JSONPostProcessor.jsonPathExprs">$.token, $.access_token, $.data.token, $.data.access_token</stringProp>',
518
+ f'{indent} <stringProp name="JSONPostProcessor.jsonPathExprs">$..[\'token\',\'access_token\']</stringProp>',
509
519
  f'{indent} <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>',
510
520
  f'{indent} <stringProp name="JSONPostProcessor.defaultValues">${{BEARER_TOKEN}}</stringProp>',
511
521
  f'{indent} </JSONPostProcessor>',
@@ -516,9 +526,9 @@ def build_jmx_xml(
516
526
  f'{indent} <!-- Auto-Extract Created Entity ID -->',
517
527
  f'{indent} <JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="JSON Extractor - Entity ID" enabled="true">',
518
528
  f'{indent} <stringProp name="JSONPostProcessor.referenceNames">LAST_CREATED_ID</stringProp>',
519
- f'{indent} <stringProp name="JSONPostProcessor.jsonPathExprs">$.id, $.code, $.data.id, $.data.code</stringProp>',
529
+ f'{indent} <stringProp name="JSONPostProcessor.jsonPathExprs">$..[\'id\',\'code\']</stringProp>',
520
530
  f'{indent} <stringProp name="JSONPostProcessor.match_numbers">1</stringProp>',
521
- f'{indent} <stringProp name="JSONPostProcessor.defaultValues"></stringProp>',
531
+ f'{indent} <stringProp name="JSONPostProcessor.defaultValues">${{LAST_CREATED_ID}}</stringProp>',
522
532
  f'{indent} </JSONPostProcessor>',
523
533
  f'{indent} <hashTree/>'
524
534
  ])
@@ -752,7 +762,10 @@ def parse_openapi_for_jmeter(spec_data: Dict[str, Any]) -> tuple:
752
762
 
753
763
  def replace_path_param(match):
754
764
  pname = match.group(1)
755
- if pname.lower() in ("id", "categoryid", "entityid", "userid"):
765
+ plow = pname.lower()
766
+ if plow in ("itemid", "productid", "item_id", "product_id"):
767
+ return "${RANDOM_ITEM_ID}"
768
+ if plow in ("id", "categoryid", "entityid", "userid", "orderid", "order_id"):
756
769
  return "${LAST_CREATED_ID}"
757
770
  return f"${{{pname}}}"
758
771
 
@@ -794,6 +807,25 @@ def parse_openapi_for_jmeter(spec_data: Dict[str, Any]) -> tuple:
794
807
  "expected_status": expected_status
795
808
  })
796
809
 
810
+ def sampler_sort_key(s: Dict[str, Any]) -> int:
811
+ p = s.get("path", "").lower()
812
+ m = s.get("method", "GET")
813
+ if "auth" in p or "login" in p or "token" in p:
814
+ return 1
815
+ if m == "POST" and "{" not in s.get("path", "") and "${" not in s.get("path", ""):
816
+ return 2
817
+ if m == "GET" and "{" not in s.get("path", "") and "${" not in s.get("path", ""):
818
+ return 3
819
+ if m == "GET":
820
+ return 4
821
+ if m in ("PUT", "PATCH"):
822
+ return 5
823
+ if m == "DELETE":
824
+ return 6
825
+ return 7
826
+
827
+ samplers.sort(key=sampler_sort_key)
828
+
797
829
  return plan_name, base_host, protocol, port, samplers
798
830
 
799
831
 
@@ -993,8 +1025,8 @@ def main():
993
1025
  plan_name, base_host, protocol, port, samplers = parse_openapi_for_jmeter(spec_data)
994
1026
  else:
995
1027
  # Fallback to built-in sample spec
996
- from export_postman_collection import get_sample_spec
997
- spec_data = get_sample_spec()
1028
+ from export_postman_collection import get_domain_spec
1029
+ spec_data = get_domain_spec("ecommerce")
998
1030
  plan_name, base_host, protocol, port, samplers = parse_openapi_for_jmeter(spec_data)
999
1031
 
1000
1032
  # Base URL override if provided
@@ -154,21 +154,40 @@ def analyze_script(file_path):
154
154
 
155
155
 
156
156
  def run_infer_linter(target_dir=None):
157
- """Scans all Groovy test scripts in target_dir or Scripts/."""
158
- scan_dir = target_dir or SCRIPTS_DIR
159
- if not os.path.exists(scan_dir):
160
- script_files = []
161
- elif os.path.isfile(scan_dir):
162
- script_files = [scan_dir]
157
+ """Scans test scripts across Groovy, TypeScript, JavaScript, Python, and Java."""
158
+ EXTENSIONS = ("*.groovy", "*.ts", "*.js", "*.py", "*.java")
159
+
160
+ if target_dir:
161
+ if os.path.isfile(target_dir):
162
+ script_files = [target_dir]
163
+ scan_dir = os.path.dirname(target_dir)
164
+ elif os.path.isdir(target_dir):
165
+ scan_dir = target_dir
166
+ script_files = []
167
+ for ext in EXTENSIONS:
168
+ script_files.extend(glob.glob(os.path.join(scan_dir, "**", ext), recursive=True))
169
+ else:
170
+ scan_dir = target_dir
171
+ script_files = []
163
172
  else:
164
- script_files = glob.glob(os.path.join(scan_dir, "**", "*.groovy"), recursive=True)
173
+ # Search candidate directories across test frameworks (Katalon, Playwright, Pytest, Cypress)
174
+ script_files = []
175
+ scan_dir = SCRIPTS_DIR
176
+ for cand in ["Scripts", "tests", "test", "e2e", "specs", "cypress"]:
177
+ cand_path = os.path.join(REPO_ROOT, cand)
178
+ if os.path.isdir(cand_path):
179
+ for ext in EXTENSIONS:
180
+ script_files.extend(glob.glob(os.path.join(cand_path, "**", ext), recursive=True))
181
+ if script_files:
182
+ scan_dir = cand_path
183
+ break
165
184
 
166
185
  results = []
167
186
  critical_count = 0
168
187
  warning_count = 0
169
188
  clean_count = 0
170
189
 
171
- for s in sorted(script_files):
190
+ for s in sorted(set(script_files)):
172
191
  res = analyze_script(s)
173
192
  results.append(res)
174
193
  crit = sum(1 for d in res["defects"] if d["severity"] == "CRITICAL")
@@ -179,17 +198,28 @@ def run_infer_linter(target_dir=None):
179
198
  clean_count += 1
180
199
 
181
200
  total_scripts = len(script_files)
182
- health_score = 100.0 if total_scripts == 0 else (((total_scripts - critical_count) / total_scripts) * 100)
183
- clean_pct = (clean_count / total_scripts * 100) if total_scripts > 0 else 100.0
201
+ if total_scripts == 0:
202
+ health_score = 0.0
203
+ clean_pct = 0.0
204
+ else:
205
+ health_score = round(((total_scripts - critical_count) / total_scripts) * 100, 1)
206
+ clean_pct = round((clean_count / total_scripts) * 100, 1)
184
207
 
185
208
  print(f"\n{BOLD}{CYAN}======================================================================{RESET}")
186
209
  print(f"{BOLD}{CYAN} ENTERPRISE STATIC DEFECT & FAKE-PASS SENTINEL (Meta Infer-Style) {RESET}")
187
210
  print(f"{BOLD}{CYAN}======================================================================{RESET}")
188
- print(f" Target Scope : {total_scripts} Groovy Test Scripts in {os.path.basename(scan_dir)}/")
189
- print(f" Critical Defects : {RED if critical_count > 0 else GREEN}{critical_count}{RESET} (Swallowed exceptions, empty scripts)")
190
- print(f" Warnings / Suggestions : {YELLOW if warning_count > 0 else GREEN}{warning_count}{RESET} (Missing explicit asserts, excessive delays)")
191
- print(f" Clean Scripts : {GREEN}{clean_count}/{total_scripts} ({clean_pct:.1f}%){RESET}")
192
- print(f" Codebase Health Score : {GREEN if health_score == 100 else YELLOW}{health_score:.1f}%{RESET}")
211
+ if total_scripts == 0:
212
+ print(f" Target Scope : 0 Test Scripts discovered in {os.path.basename(scan_dir)}/ (No tests found)")
213
+ print(f" Critical Defects : {GREEN}0{RESET}")
214
+ print(f" Warnings / Suggestions : {GREEN}0{RESET}")
215
+ print(f" Clean Scripts : 0/0 (N/A)")
216
+ print(f" Codebase Health Score : {YELLOW}N/A (0 test scripts discovered){RESET}")
217
+ else:
218
+ print(f" Target Scope : {total_scripts} Test Scripts in {os.path.basename(scan_dir)}/ (Polyglot)")
219
+ print(f" Critical Defects : {RED if critical_count > 0 else GREEN}{critical_count}{RESET} (Swallowed exceptions, empty scripts)")
220
+ print(f" Warnings / Suggestions : {YELLOW if warning_count > 0 else GREEN}{warning_count}{RESET} (Missing explicit asserts, excessive delays)")
221
+ print(f" Clean Scripts : {GREEN}{clean_count}/{total_scripts} ({clean_pct:.1f}%){RESET}")
222
+ print(f" Codebase Health Score : {GREEN if health_score == 100 else YELLOW}{health_score:.1f}%{RESET}")
193
223
  print(f"{BOLD}{CYAN}======================================================================{RESET}\n")
194
224
 
195
225
  # Print defect breakdown if any
@@ -362,7 +362,7 @@ def main():
362
362
  parser.add_argument("--sla-p95", type=float, default=None, help="P95 Latency threshold in ms (fails if exceeded).")
363
363
  parser.add_argument("--sla-p99", type=float, default=None, help="P99 Latency threshold in ms (fails if exceeded).")
364
364
  parser.add_argument("--sla-avg", type=float, default=None, help="Average Latency threshold in ms (fails if exceeded).")
365
- parser.add_argument("--max-error-rate", type=float, default=None, help="Maximum allowed error rate percentage (e.g. 1.0 = 1%).")
365
+ parser.add_argument("--max-error-rate", type=float, default=None, help="Maximum allowed error rate percentage (e.g. 1.0 = 1%%).")
366
366
  parser.add_argument("--markdown", "-m", help="Output path for Markdown summary report.")
367
367
  parser.add_argument("--json", "-j", help="Output path for JSON summary metrics.")
368
368
  parser.add_argument("--telemetry", help="Path to server telemetry CSV (timestamp, cpu_pct, memory_pct, db_pool_pct) for Full-Stack APM correlation.")
@@ -160,7 +160,7 @@ while (($line = $inputReader.ReadLine()) -ne $null) {
160
160
  result = @{
161
161
  protocolVersion = "2024-11-05"
162
162
  capabilities = @{ tools = @{} }
163
- serverInfo = @{ name = "excel-qa-mcp-server"; version = "2.2.2" }
163
+ serverInfo = @{ name = "excel-qa-mcp-server"; version = "2.2.4" }
164
164
  }
165
165
  }
166
166
  [System.Console]::Out.WriteLine(($resp | ConvertTo-Json -Compress -Depth 10))
@@ -43,7 +43,7 @@ import traffic_session_converter
43
43
  import visual_som_helper
44
44
 
45
45
  SERVER_NAME = "enterprise-agent-qa"
46
- SERVER_VERSION = "2.2.2"
46
+ SERVER_VERSION = "2.2.4"
47
47
 
48
48
  MCP_TOOLS = [
49
49
  {
@@ -68,7 +68,7 @@ ASSERTION_PATTERN = re.compile(
68
68
 
69
69
  # Tautological / fake assertions that do not test application logic
70
70
  TAUTOLOGY_PATTERN = re.compile(
71
- r'(?:assert\s+(?:true|1\s*==\s*1)\b|assertEquals\s*\(\s*true\s*,\s*true\s*\)|expect\s*\(\s*true\s*\)\.toBe\s*\(\s*true\s*\)|assertTrue\s*\(\s*true\s*\))',
71
+ r'(?:assert\s+(?:true|1\s*==\s*1)\b|assert\s+([a-zA-Z_]\w*)\s*==\s*\1\b|assertEquals\s*\(\s*([a-zA-Z_]\w*)\s*,\s*\2\s*\)|expect\s*\(\s*([a-zA-Z_]\w*)\s*\)\.toBe\s*\(\s*\3\s*\)|assertTrue\s*\(\s*true\s*\))',
72
72
  re.IGNORECASE
73
73
  )
74
74
 
@@ -195,7 +195,12 @@ def evaluate_mutants_statically(code: str, mutants: List[Dict[str, Any]]) -> Tup
195
195
  - If mutant is inside an unasserted block or swallowed exception: mutant SURVIVES.
196
196
  """
197
197
  if not mutants:
198
- return mutants, 100.0
198
+ has_asserts = any(
199
+ ASSERTION_PATTERN.search(l) and not TAUTOLOGY_PATTERN.search(l)
200
+ for l in code.splitlines()
201
+ if not l.strip().startswith(("//", "#", "/*"))
202
+ )
203
+ return mutants, (100.0 if has_asserts else 0.0)
199
204
 
200
205
  lines = code.splitlines()
201
206
  has_empty_catch = bool(EMPTY_CATCH_PATTERN.search(code))
@@ -245,6 +250,55 @@ def evaluate_mutants_statically(code: str, mutants: List[Dict[str, Any]]) -> Tup
245
250
  return mutants, score
246
251
 
247
252
 
253
+ def evaluate_mutants_dynamically(file_path: str, code: str, mutants: List[Dict[str, Any]], test_cmd: str) -> Tuple[List[Dict[str, Any]], float]:
254
+ """
255
+ Executes live mutation testing by injecting mutants into disk file and executing test_cmd.
256
+ If the test command fails (exit code != 0), the mutant is KILLED by the test suite.
257
+ If the test command passes (exit code == 0), the mutant SURVIVED (unasserted logic branch).
258
+ Safely restores original file content in all cases.
259
+ """
260
+ if not mutants:
261
+ return mutants, 0.0
262
+
263
+ killed_count = 0
264
+ abs_path = os.path.abspath(file_path)
265
+
266
+ try:
267
+ for m in mutants:
268
+ mutated_code = m["mutated_code"]
269
+ with open(abs_path, "w", encoding="utf-8") as f:
270
+ f.write(mutated_code)
271
+
272
+ try:
273
+ proc = subprocess.run(
274
+ test_cmd,
275
+ shell=True,
276
+ capture_output=True,
277
+ timeout=30,
278
+ text=True
279
+ )
280
+ if proc.returncode != 0:
281
+ m["status"] = "KILLED"
282
+ m["reason"] = f"Dynamic test caught mutant: command failed (exit code {proc.returncode})"
283
+ killed_count += 1
284
+ else:
285
+ m["status"] = "SURVIVED"
286
+ m["reason"] = "Dynamic test suite passed unconditionally: missing assertion for this mutant."
287
+ except subprocess.TimeoutExpired:
288
+ m["status"] = "KILLED"
289
+ m["reason"] = "Mutant triggered execution timeout (> 30s): caught by runtime guard"
290
+ killed_count += 1
291
+ except Exception as e:
292
+ m["status"] = "SURVIVED"
293
+ m["reason"] = f"Test execution error: {e}"
294
+ finally:
295
+ with open(abs_path, "w", encoding="utf-8") as f:
296
+ f.write(code)
297
+
298
+ score = evaluate_mutation_score(killed_count, len(mutants))
299
+ return mutants, score
300
+
301
+
248
302
  def scan_directory_for_mutants(target_dir: str) -> List[Dict[str, Any]]:
249
303
  """Scans target test/page scripts and generates a collection of mutants."""
250
304
  all_mutants = []
@@ -269,21 +323,23 @@ def scan_directory_for_mutants(target_dir: str) -> List[Dict[str, Any]]:
269
323
  def evaluate_target(target_path: str, min_score: float = 80.0, test_cmd: str = "") -> Dict[str, Any]:
270
324
  """
271
325
  Evaluates target file or directory for mutation robustness.
272
- Returns complete mutation report dictionary.
326
+ Dispatches to live test execution if test_cmd is provided, or static heuristic otherwise.
273
327
  """
274
328
  target = os.path.abspath(target_path)
275
329
  all_mutants = []
330
+ exec_mode = "DYNAMIC" if test_cmd else "STATIC"
276
331
 
277
332
  if os.path.isfile(target):
278
333
  with open(target, "r", encoding="utf-8", errors="ignore") as f:
279
334
  code = f.read()
280
335
  mutants = generate_mutants_for_code(code, file_path=os.path.basename(target))
281
- evaluated_mutants, score = evaluate_mutants_statically(code, mutants)
336
+ if test_cmd:
337
+ evaluated_mutants, score = evaluate_mutants_dynamically(target, code, mutants, test_cmd)
338
+ else:
339
+ evaluated_mutants, score = evaluate_mutants_statically(code, mutants)
282
340
  all_mutants.extend(evaluated_mutants)
283
341
  else:
284
- # Directory evaluation
285
342
  valid_exts = {".groovy", ".py", ".ts", ".js"}
286
- total_killed = 0
287
343
  for root, _, files in os.walk(target):
288
344
  for f in files:
289
345
  if any(f.endswith(ext) for ext in valid_exts):
@@ -293,7 +349,10 @@ def evaluate_target(target_path: str, min_score: float = 80.0, test_cmd: str = "
293
349
  code = fp.read()
294
350
  rel_path = os.path.relpath(file_full, target)
295
351
  f_mutants = generate_mutants_for_code(code, file_path=rel_path)
296
- eval_m, _ = evaluate_mutants_statically(code, f_mutants)
352
+ if test_cmd:
353
+ eval_m, _ = evaluate_mutants_dynamically(file_full, code, f_mutants, test_cmd)
354
+ else:
355
+ eval_m, _ = evaluate_mutants_statically(code, f_mutants)
297
356
  all_mutants.extend(eval_m)
298
357
  except Exception:
299
358
  pass
@@ -305,6 +364,18 @@ def evaluate_target(target_path: str, min_score: float = 80.0, test_cmd: str = "
305
364
  survived = sum(1 for m in all_mutants if m["status"] == "SURVIVED")
306
365
  passed = score >= min_score
307
366
 
367
+ return {
368
+ "target": target,
369
+ "mode": exec_mode,
370
+ "total_mutants": len(all_mutants),
371
+ "killed": killed,
372
+ "survived": survived,
373
+ "mutation_score": score,
374
+ "min_score": min_score,
375
+ "passed": passed,
376
+ "mutants": all_mutants
377
+ }
378
+
308
379
  return {
309
380
  "target": target,
310
381
  "total_mutants": len(all_mutants),