synapse-hub 2.2.2 → 2.2.3

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.
@@ -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
@@ -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.3" }
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.3"
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),
package/tools/repo_map.py CHANGED
@@ -21,10 +21,11 @@ HUB_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, ".."))
21
21
  OUTPUT_TXT = os.path.join(SCRIPT_DIR, "repo_map.txt")
22
22
  OUTPUT_JSON = os.path.join(SCRIPT_DIR, "repo_map.json")
23
23
 
24
- # Regex patterns for Groovy classes and methods
25
- CLASS_PATTERN = re.compile(r'^\s*(?:public\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?', re.MULTILINE)
24
+ # Regex patterns for Groovy/Java classes and methods
25
+ CLASS_PATTERN = re.compile(r'^\s*(?:public\s+|export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?', re.MULTILINE)
26
+ PY_CLASS_PATTERN = re.compile(r'^\s*class\s+(\w+)(?:\(([^)]*)\))?\s*:', re.MULTILINE)
26
27
  METHOD_PATTERN = re.compile(
27
- r'^\s*(?:@\w+\s+)*(?:public\s+|protected\s+|private\s+)?(?:static\s+)?(?:def\s+|void\s+|[\w<>\[\],]+\s+)?(\w+)\s*\(([^)]*)\)\s*(?:throws\s+[\w,\s]+)?\s*\{',
28
+ r'^\s*(?:@\w+\s+)*(?:public\s+|protected\s+|private\s+)?(?:static\s+)?(?:async\s+)?(?:def\s+|void\s+|[\w<>\[\],]+\s+)?(\w+)\s*\(([^)]*)\)\s*(?:throws\s+[\w,\s]+)?\s*\{',
28
29
  re.MULTILINE
29
30
  )
30
31
  DOC_PATTERN = re.compile(r'/\*\*(.*?)\*/', re.DOTALL)
@@ -35,7 +36,7 @@ def resolve_target_repo(cli_target=None, cli_repo=None):
35
36
  Resolves the target repository path for repo mapping:
36
37
  1. Explicit CLI flag: --repo / --path or positional target.
37
38
  2. Environment variable: TARGET_PROJECT_DIR.
38
- 3. Current Working Directory (if different from Hub and has Keywords/ or Scripts/).
39
+ 3. Current Working Directory (if different from Hub and has Keywords/, pages/, or tests/).
39
40
  4. Default fallback: QA Hub root (parent of tools/).
40
41
  """
41
42
  chosen = cli_repo or cli_target
@@ -51,14 +52,15 @@ def resolve_target_repo(cli_target=None, cli_repo=None):
51
52
 
52
53
  cwd = os.path.abspath(os.getcwd())
53
54
  if cwd.lower() != HUB_ROOT.lower():
54
- if os.path.exists(os.path.join(cwd, "Keywords")) or os.path.exists(os.path.join(cwd, "Scripts")):
55
+ markers = ("Keywords", "Scripts", "pages", "tests", "src/pages", "e2e")
56
+ if any(os.path.exists(os.path.join(cwd, m)) for m in markers):
55
57
  return cwd
56
58
 
57
59
  return HUB_ROOT
58
60
 
59
61
 
60
62
  def extract_class_info(file_path, repo_root=None):
61
- """Parses a Groovy file and extracts class name, parent, and exported methods."""
63
+ """Parses a Groovy, TypeScript, JavaScript, or Python file and extracts class name and methods."""
62
64
  with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
63
65
  content = f.read()
64
66
 
@@ -68,48 +70,72 @@ def extract_class_info(file_path, repo_root=None):
68
70
  except ValueError:
69
71
  rel_path = os.path.abspath(file_path)
70
72
 
71
- class_match = CLASS_PATTERN.search(content)
72
- if not class_match:
73
- return None
74
-
75
- class_name = class_match.group(1)
76
- extends_name = class_match.group(2) or ""
77
-
73
+ ext = os.path.splitext(file_path)[1].lower()
78
74
  methods = []
79
- control_keywords = {"if", "for", "while", "switch", "catch", "synchronized"}
80
-
81
- lines = content.splitlines()
82
- for idx, line in enumerate(lines):
83
- line_clean = line.strip()
84
- if not line_clean or line_clean.startswith("//") or line_clean.startswith("/*") or line_clean.startswith("*"):
85
- continue
86
-
87
- # If '=' appears before '(', it's a variable assignment / constant declaration, not a method
88
- if "=" in line_clean:
89
- eq_pos = line_clean.find("=")
90
- paren_pos = line_clean.find("(")
91
- if paren_pos != -1 and eq_pos < paren_pos:
75
+ control_keywords = {"if", "for", "while", "switch", "catch", "synchronized", "constructor"}
76
+
77
+ if ext in (".groovy", ".java", ".ts", ".js"):
78
+ class_match = CLASS_PATTERN.search(content)
79
+ if not class_match:
80
+ return None
81
+ class_name = class_match.group(1)
82
+ extends_name = class_match.group(2) or ""
83
+
84
+ lines = content.splitlines()
85
+ for idx, line in enumerate(lines):
86
+ line_clean = line.strip()
87
+ if not line_clean or line_clean.startswith(("//", "/*", "*")):
92
88
  continue
93
89
 
94
- # Must have parentheses and end with '{' or next line starts with '{'
95
- if "(" in line_clean and ")" in line_clean:
96
- has_brace = line_clean.endswith("{") or (idx + 1 < len(lines) and lines[idx + 1].strip().startswith("{"))
97
- if has_brace:
98
- # Match method definition: modifiers + return type + name + (params)
99
- m = re.search(r'(?:public\s+|protected\s+|private\s+|static\s+|def\s+|void\s+|[\w<>\[\],]+\s+)+([a-zA-Z_]\w*)\s*\(([^)]*)\)', line_clean)
90
+ if "=" in line_clean:
91
+ eq_pos = line_clean.find("=")
92
+ paren_pos = line_clean.find("(")
93
+ if paren_pos != -1 and eq_pos < paren_pos:
94
+ continue
95
+
96
+ if "(" in line_clean and ")" in line_clean:
97
+ has_brace = "{" in line_clean or (idx + 1 < len(lines) and lines[idx + 1].strip().startswith("{"))
98
+ if has_brace:
99
+ m = re.search(r'(?:public\s+|protected\s+|private\s+|static\s+|async\s+|def\s+|void\s+|[\w<>\[\],]+\s+)*([a-zA-Z_]\w*)\s*\(([^)]*)\)', line_clean)
100
+ if m:
101
+ name = m.group(1)
102
+ params = m.group(2).strip()
103
+ if name not in control_keywords and name != class_name:
104
+ comment = ""
105
+ if idx > 0 and lines[idx - 1].strip().startswith("//"):
106
+ comment = lines[idx - 1].strip().lstrip("/ ").strip()
107
+ methods.append({
108
+ "name": name,
109
+ "params": re.sub(r'\s+', ' ', params),
110
+ "comment": comment
111
+ })
112
+
113
+ elif ext == ".py":
114
+ class_match = PY_CLASS_PATTERN.search(content)
115
+ if not class_match:
116
+ return None
117
+ class_name = class_match.group(1)
118
+ extends_name = class_match.group(2) or ""
119
+
120
+ lines = content.splitlines()
121
+ for idx, line in enumerate(lines):
122
+ line_clean = line.strip()
123
+ if line_clean.startswith("def "):
124
+ m = re.search(r'def\s+([a-zA-Z_]\w*)\s*\(([^)]*)\):', line_clean)
100
125
  if m:
101
126
  name = m.group(1)
102
- params = m.group(2).strip()
103
- if name not in control_keywords and name != class_name:
104
- # Extract preceding single-line comment if any
127
+ params = m.group(2).replace("self,", "").replace("self", "").strip()
128
+ if not name.startswith("__") and name not in control_keywords:
105
129
  comment = ""
106
- if idx > 0 and lines[idx - 1].strip().startswith("//"):
107
- comment = lines[idx - 1].strip().lstrip("/ ").strip()
130
+ if idx + 1 < len(lines) and '"""' in lines[idx + 1]:
131
+ comment = lines[idx + 1].replace('"""', '').strip()
108
132
  methods.append({
109
133
  "name": name,
110
134
  "params": re.sub(r'\s+', ' ', params),
111
135
  "comment": comment
112
136
  })
137
+ else:
138
+ return None
113
139
 
114
140
  # Deduplicate methods preserving order
115
141
  seen = set()
@@ -120,6 +146,7 @@ def extract_class_info(file_path, repo_root=None):
120
146
  seen.add(sig)
121
147
  unique_methods.append(meth)
122
148
 
149
+ lines = content.splitlines()
123
150
  return {
124
151
  "rel_path": rel_path,
125
152
  "class_name": class_name,
@@ -131,13 +158,7 @@ def extract_class_info(file_path, repo_root=None):
131
158
 
132
159
 
133
160
  def build_repo_map(target_repo=None, output_txt=None, output_json=None):
134
- """Scans Keywords in target_repo and builds the condensed repo map."""
135
- try:
136
- from author_integrity import assert_integrity_or_raise
137
- assert_integrity_or_raise()
138
- except Exception:
139
- pass
140
-
161
+ """Scans Page Classes across polyglot targets (Groovy, TypeScript, Python) and builds map."""
141
162
  repo = target_repo or resolve_target_repo()
142
163
  txt_dest = output_txt or OUTPUT_TXT
143
164
  json_dest = output_json or OUTPUT_JSON
@@ -146,18 +167,31 @@ def build_repo_map(target_repo=None, output_txt=None, output_json=None):
146
167
  total_raw_bytes = 0
147
168
  total_raw_lines = 0
148
169
 
149
- keywords_dir = os.path.join(repo, "Keywords")
150
-
151
- if os.path.isdir(keywords_dir):
152
- for root, dirs, files in os.walk(keywords_dir):
153
- for fname in sorted(files):
154
- if fname.endswith(".groovy"):
155
- full_path = os.path.join(root, fname)
156
- info = extract_class_info(full_path, repo_root=repo)
157
- if info:
158
- classes.append(info)
159
- total_raw_bytes += info["raw_size_bytes"]
160
- total_raw_lines += info["line_count"]
170
+ candidate_dirs = [
171
+ os.path.join(repo, "Keywords"),
172
+ os.path.join(repo, "pages"),
173
+ os.path.join(repo, "page-objects"),
174
+ os.path.join(repo, "page_objects"),
175
+ os.path.join(repo, "src", "pages"),
176
+ os.path.join(repo, "tests", "pages"),
177
+ ]
178
+
179
+ scanned_files = set()
180
+ for d in candidate_dirs:
181
+ if os.path.isdir(d):
182
+ for root, _, files in os.walk(d):
183
+ if any(ignored in root for ignored in (".git", "node_modules", "__pycache__", ".venv")):
184
+ continue
185
+ for fname in sorted(files):
186
+ if fname.endswith((".groovy", ".ts", ".js", ".py", ".java")):
187
+ full_path = os.path.join(root, fname)
188
+ if full_path not in scanned_files:
189
+ scanned_files.add(full_path)
190
+ info = extract_class_info(full_path, repo_root=repo)
191
+ if info and info.get("methods"):
192
+ classes.append(info)
193
+ total_raw_bytes += info["raw_size_bytes"]
194
+ total_raw_lines += info["line_count"]
161
195
 
162
196
  # Sort classes: BasePage first, then alphabetical
163
197
  classes.sort(key=lambda c: (0 if c["class_name"] == "BasePage" else 1, c["class_name"]))
@@ -175,7 +175,10 @@ def ensure_project_ai_guide(target_dir, stack_info):
175
175
 
176
176
 
177
177
  def install_git_pre_push_hook(target_dir):
178
- """Installs an automated pre-push hook into target project's .git repository."""
178
+ """
179
+ Installs or appends an automated pre-push hook into target project's .git repository.
180
+ Safely backs up any existing pre-push hook (e.g. Husky, Lefthook) to prevent side-effects.
181
+ """
179
182
  git_dir = os.path.join(target_dir, ".git")
180
183
  if not os.path.isdir(git_dir):
181
184
  print(" [SKIP] Not a Git repository root (.git folder not found). Skipped Git Hook installation.")
@@ -185,37 +188,64 @@ def install_git_pre_push_hook(target_dir):
185
188
  os.makedirs(hooks_dir, exist_ok=True)
186
189
  hook_file = os.path.join(hooks_dir, "pre-push")
187
190
 
191
+ # Resolve pinned package version
192
+ manifest_file = os.path.join(HUB_ROOT, "skills_manifest.json")
193
+ pkg_ver = "2.2.2"
194
+ if os.path.exists(manifest_file):
195
+ try:
196
+ with open(manifest_file, "r", encoding="utf-8") as f:
197
+ pkg_ver = json.load(f).get("version", pkg_ver)
198
+ except Exception:
199
+ pass
200
+
188
201
  verify_all_script = os.path.join(SCRIPT_DIR, "verify_all.py").replace("\\", "/")
189
202
  analyze_script = os.path.join(SCRIPT_DIR, "analyze_project.py").replace("\\", "/")
190
203
 
191
- hook_content = f"""#!/bin/sh
192
- # Automated Zero-Leakage Pre-Push Hook (Installed by Synapse-Hub)
193
- # Enforces 8 Quality Gates DoD and syncs project intelligence before pushing.
194
-
195
- echo "\\n[AI-GUARD] Running Pre-Push Verification (8 Quality Gates DoD)..."
204
+ hook_snippet = f"""
205
+ # --- Synapse-Hub Pre-Push Quality Gate (v{pkg_ver}) ---
206
+ echo "\\n[AI-GUARD] Running Pre-Push Verification (Quality Gates DoD)..."
196
207
  if command -v npx >/dev/null 2>&1; then
197
- npx synapse-hub verify
208
+ npx synapse-hub@{pkg_ver} verify || npx synapse-hub verify
198
209
  elif [ -f "{verify_all_script}" ]; then
199
210
  python3 "{verify_all_script}" || python "{verify_all_script}"
200
211
  fi
201
212
  if [ $? -ne 0 ]; then
202
213
  echo "\\n[AI-GUARD - REJECTED] Git Push aborted: Quality Gates verification failed!"
203
- echo "Please fix defects or run 'npx synapse-hub verify' locally before pushing.\\n"
214
+ echo "Please fix defects or run 'npx synapse-hub@{pkg_ver} verify' locally before pushing.\\n"
204
215
  exit 1
205
216
  fi
206
217
 
207
- echo "[AI-GUARD - PASSED] 8 Quality Gates satisfied. Syncing project health report..."
218
+ echo "[AI-GUARD - PASSED] Quality Gates satisfied. Syncing project health report..."
208
219
  if command -v npx >/dev/null 2>&1; then
209
- npx synapse-hub analyze
220
+ npx synapse-hub@{pkg_ver} analyze || npx synapse-hub analyze
210
221
  elif [ -f "{analyze_script}" ]; then
211
222
  python3 "{analyze_script}" || python "{analyze_script}"
212
223
  fi
213
224
  echo "[AI-GUARD - READY] Pushing to remote...\\n"
214
- exit 0
225
+ # --- End Synapse-Hub Quality Gate ---
215
226
  """
227
+
216
228
  try:
229
+ if os.path.exists(hook_file):
230
+ with open(hook_file, "r", encoding="utf-8", errors="ignore") as f:
231
+ existing_content = f.read()
232
+
233
+ if "Synapse-Hub" in existing_content or "synapse-hub" in existing_content:
234
+ print(f" {CYAN}[SKIP] Pre-Push Git Hook already present:{RESET} {hook_file}")
235
+ return True
236
+
237
+ # Safely backup existing hook (e.g. Husky, Lefthook)
238
+ backup_file = f"{hook_file}.backup"
239
+ shutil.copy2(hook_file, backup_file)
240
+ print(f" {YELLOW}[BACKUP] Existing pre-push hook backed up to:{RESET} {backup_file}")
241
+
242
+ # Append non-destructively
243
+ final_content = existing_content.rstrip() + "\n" + hook_snippet
244
+ else:
245
+ final_content = "#!/bin/sh\n" + hook_snippet
246
+
217
247
  with open(hook_file, "w", encoding="utf-8", newline="\n") as f:
218
- f.write(hook_content)
248
+ f.write(final_content)
219
249
 
220
250
  # Attempt to make executable on Unix-like environments
221
251
  try:
@@ -223,7 +253,7 @@ exit 0
223
253
  except Exception:
224
254
  pass
225
255
 
226
- print(f" {GREEN}[OK] Installed automated Pre-Push Git Hook:{RESET} {hook_file}")
256
+ print(f" {GREEN}[OK] Configured automated Pre-Push Git Hook:{RESET} {hook_file} (pinned to v{pkg_ver})")
227
257
  return True
228
258
  except Exception as e:
229
259
  print(f" ⚠️ Could not install Git hook: {e}")