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.
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"]))
@@ -41,6 +41,13 @@ DANGEROUS_ACTION_KEYWORDS = [
41
41
  r"\breset\b",
42
42
  r"\btransfer\b",
43
43
  r"\bformat\b",
44
+ # Multilingual & Vietnamese destructive keywords
45
+ r"x[oóòỏõọ][aáàảãạ]",
46
+ r"x[oóòỏõọ]a\s+t[aáàảãạ]t\s+c[aảáàãạ]",
47
+ r"h[uủúùũụ]y",
48
+ r"thanh\s+to[aáàảãạ]n",
49
+ r"r[uúùủũụ]t\s+ti[eềéèẻẽẹ]n",
50
+ r"n[aạáàảã]p\s+ti[eềéèẻẽẹ]n",
44
51
  ]
45
52
 
46
53
  DANGEROUS_SELECTOR_PATTERNS = [
@@ -50,6 +57,8 @@ DANGEROUS_SELECTOR_PATTERNS = [
50
57
  r"\.p-button-danger\b",
51
58
  r"\[aria-label\*=['\"]?delete['\"]?\]",
52
59
  r"button[#\.\-_a-zA-Z0-9]*[_\-]*(?:del|delete|purge|remove|wipe|drop)",
60
+ r":(?:has-text|text)\s*\(\s*['\"][^'\"]*(?:delete|remove|wipe|purge|pay|x[oóòỏõọ][aáàảãạ]|h[uủúùũụ]y|thanh\s*to[aáàảãạ]n)[^'\"]*['\"]\s*\)",
61
+ r"text=['\"][^'\"]*(?:delete|remove|wipe|purge|pay|x[oóòỏõọ][aáàảãạ]|h[uủúùũụ]y|thanh\s*to[aáàảãạ]n)",
53
62
  ]
54
63
 
55
64
  COMPILED_TEXT_REGEX = re.compile("|".join(DANGEROUS_ACTION_KEYWORDS), re.IGNORECASE)
@@ -84,8 +93,12 @@ def is_action_dangerous(action_type: str, text: str = "", selector: str = "") ->
84
93
  return True, f"Element text contains dangerous keyword: '{match}'"
85
94
 
86
95
  # Check selector
87
- if sel_clean and COMPILED_SEL_REGEX.search(sel_clean):
88
- return True, f"Element selector matches destructive pattern: '{sel_clean}'"
96
+ if sel_clean:
97
+ if COMPILED_SEL_REGEX.search(sel_clean):
98
+ return True, f"Element selector matches destructive pattern: '{sel_clean}'"
99
+ if COMPILED_TEXT_REGEX.search(sel_clean):
100
+ match = COMPILED_TEXT_REGEX.search(sel_clean).group(0)
101
+ return True, f"Element selector contains dangerous keyword: '{match}' in '{sel_clean}'"
89
102
 
90
103
  # Specific dangerous action types
91
104
  if action_type.lower() in ("drop_database", "wipe_environment", "execute_payment"):
@@ -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}")
@@ -165,7 +165,7 @@ PROHIBITION_PREFIX = re.compile(
165
165
  )
166
166
 
167
167
  ALERT_BLOCK_PREFIX = re.compile(
168
- r"^\s*>\s*\[!(?:WARNING|CAUTION|DANGER|ALERT|IMPORTANT|NOTE|TIP)\]",
168
+ r"^\s*>\s*\[!(?:WARNING|CAUTION|DANGER|ALERT)\]",
169
169
  re.IGNORECASE | re.MULTILINE,
170
170
  )
171
171
 
@@ -189,23 +189,20 @@ def is_in_prohibition_context(content: str, start: int, end: int) -> bool:
189
189
  if PROHIBITION_PREFIX.search(current_line):
190
190
  return True
191
191
 
192
- # 2. Markdown Caution/Warning Callout Block
193
- if any(ALERT_BLOCK_PREFIX.search(line) for line in lines[-5:]):
192
+ # 2. Markdown Caution/Warning Callout Block with explicit negative imperative
193
+ has_alert = any(ALERT_BLOCK_PREFIX.search(line) for line in lines[-5:])
194
+ if has_alert and any(re.search(r'\b(never|do not|don[\'’]t|must not|forbidden|prohibited|avoid)\b', line, re.IGNORECASE) for line in lines[-5:]):
194
195
  return True
195
196
 
196
197
  # 3. Defensive Guideline headings ("Anti-Pattern Blacklist", "Forbidden actions")
197
198
  preceding_lower = preceding_text.lower()
198
199
  defensive_markers = (
199
- "anti-pattern",
200
+ "anti-pattern blacklist",
200
201
  "mistake / anti-pattern",
201
202
  "strictly forbidden",
202
203
  "never use",
203
204
  "mandatory avoidance",
204
205
  "forbidden actions",
205
- "prohibited",
206
- "blacklist",
207
- "threat model",
208
- "vulnerability check",
209
206
  "zero-token error blacklist",
210
207
  )
211
208
  if any(marker in preceding_lower for marker in defensive_markers):
@@ -927,6 +924,9 @@ def scan_target(target_path: str) -> ScanReport:
927
924
  pass
928
925
 
929
926
  path_obj = Path(target_path).resolve()
927
+ if not path_obj.exists():
928
+ raise FileNotFoundError(f"Target path does not exist: {target_path}")
929
+
930
930
  all_findings: List[Finding] = []
931
931
  scanned_count = 0
932
932
 
@@ -237,43 +237,42 @@ def gate_2_path_portability(repo_root, mode="AUTO"):
237
237
 
238
238
 
239
239
  def gate_3_clean_pom(repo_root, mode="AUTO"):
240
- print("\n[GATE 3] Checking Clean POM & Flat Architecture in Scripts/......")
241
- scripts_dir = os.path.join(repo_root, "Scripts")
242
-
243
- has_groovy = False
244
- if os.path.isdir(scripts_dir):
245
- groovy_files = [f for f in glob.glob(os.path.join(scripts_dir, "**", "*.groovy"), recursive=True)]
246
- has_groovy = len(groovy_files) > 0
247
-
248
- if not has_groovy:
249
- if mode == "HUB":
250
- print("[SKIP] Hub Mode: Repository contains QA Hub assets; no target Groovy scripts in Scripts/ to verify.")
251
- return True
252
- else:
253
- print("[SKIP] No Groovy test scripts present in Scripts/ directory.")
254
- return True
240
+ print("\n[GATE 3] Checking Clean POM & Flat Architecture in Test Scripts...")
241
+ effective_mode = detect_repo_mode(repo_root) if mode == "AUTO" else mode
242
+ if effective_mode == "HUB":
243
+ print("[PASS] Hub Mode: Repository contains QA Hub assets; Clean POM applies to target test suites.")
244
+ return True
245
+
246
+ test_dirs = ["Scripts", "tests", "test", "e2e", "specs", "cypress"]
247
+ test_files = []
248
+ for td in test_dirs:
249
+ d = os.path.join(repo_root, td)
250
+ if os.path.isdir(d):
251
+ for ext in ("*.groovy", "*.ts", "*.js", "*.py", "*.java"):
252
+ test_files.extend(glob.glob(os.path.join(d, "**", ext), recursive=True))
253
+
254
+ if not test_files:
255
+ print("[SKIP] No test scripts present in candidate directories (Scripts/, tests/, e2e/).")
256
+ return True
255
257
 
256
258
  violations = []
257
- for root, dirs, files in os.walk(scripts_dir):
258
- for f in files:
259
- if f.endswith(".groovy"):
260
- full_path = os.path.join(root, f)
261
- rel_path = os.path.relpath(full_path, repo_root)
262
- try:
263
- with open(full_path, "r", encoding="utf-8", errors="ignore") as fp:
264
- for line_no, line in enumerate(fp, 1):
265
- stripped = line.strip()
266
- if stripped.startswith("//") or stripped.startswith("/*"):
267
- continue
268
- if "findTestObject" in line:
269
- violations.append((rel_path, line_no, "Raw findTestObject"))
270
- if "//" in line and ("//table" in line or "//input" in line or "//button" in line):
271
- violations.append((rel_path, line_no, "Raw XPath"))
272
- except Exception:
273
- pass
259
+ for full_path in sorted(set(test_files)):
260
+ rel_path = os.path.relpath(full_path, repo_root)
261
+ try:
262
+ with open(full_path, "r", encoding="utf-8", errors="ignore") as fp:
263
+ for line_no, line in enumerate(fp, 1):
264
+ stripped = line.strip()
265
+ if stripped.startswith("//") or stripped.startswith("/*") or stripped.startswith("#"):
266
+ continue
267
+ if "findTestObject" in line:
268
+ violations.append((rel_path, line_no, "Raw findTestObject"))
269
+ if "//" in line and ("//table" in line or "//input" in line or "//button" in line):
270
+ violations.append((rel_path, line_no, "Raw XPath"))
271
+ except Exception:
272
+ pass
274
273
 
275
274
  if violations:
276
- print(f"[FAIL] {len(violations)} Clean POM violations in Scripts/:")
275
+ print(f"[FAIL] {len(violations)} Clean POM violations in test scripts:")
277
276
  for rel_path, line_no, reason in violations:
278
277
  print(f" - {rel_path}:{line_no} ({reason})")
279
278
  return False
@@ -404,26 +403,35 @@ def gate_6_credential_security(repo_root, mode="AUTO"):
404
403
 
405
404
  def gate_7_infer_linter(repo_root, mode="AUTO"):
406
405
  print("\n[GATE 7] Running Meta Infer-Style Static Defect & Fake-Pass Sentinel...")
407
- scripts_dir = os.path.join(repo_root, "Scripts")
408
- has_groovy = False
409
- if os.path.isdir(scripts_dir):
410
- groovy_files = [f for f in glob.glob(os.path.join(scripts_dir, "**", "*.groovy"), recursive=True)]
411
- has_groovy = len(groovy_files) > 0
412
-
413
- if not has_groovy:
414
- if mode == "HUB":
415
- print("[SKIP] Hub Mode: No Groovy test scripts in Scripts/ to scan (Static Defect Sentinel active for target projects).")
416
- return True
417
- else:
418
- print("[SKIP] No Groovy test scripts present in Scripts/ directory.")
419
- return True
406
+ effective_mode = detect_repo_mode(repo_root) if mode == "AUTO" else mode
407
+ if effective_mode == "HUB":
408
+ print("[PASS] Hub Mode: Static Defect Sentinel active for target projects.")
409
+ return True
410
+
411
+ test_dirs = ["Scripts", "tests", "test", "e2e", "specs", "cypress"]
412
+ has_tests = False
413
+ target_scan_dir = None
414
+ for td in test_dirs:
415
+ d = os.path.join(repo_root, td)
416
+ if os.path.isdir(d):
417
+ for ext in ("*.groovy", "*.ts", "*.js", "*.py", "*.java"):
418
+ if glob.glob(os.path.join(d, "**", ext), recursive=True):
419
+ has_tests = True
420
+ target_scan_dir = d
421
+ break
422
+ if has_tests:
423
+ break
424
+
425
+ if not has_tests:
426
+ print("[SKIP] No test scripts (Groovy/TS/JS/Py) present in candidate test directories.")
427
+ return True
420
428
 
421
429
  infer_script = os.path.join(SCRIPT_DIR, "infer_linter.py")
422
430
  if not os.path.exists(infer_script):
423
431
  infer_script = os.path.join(repo_root, "tools", "infer_linter.py")
424
432
 
425
433
  if os.path.exists(infer_script):
426
- res = subprocess.run([sys.executable, infer_script, "--dir", scripts_dir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
434
+ res = subprocess.run([sys.executable, infer_script, "--dir", target_scan_dir], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
427
435
  if res.returncode != 0:
428
436
  print("[FAIL] Static Defect Sentinel detected critical issues in test scripts:")
429
437
  print(res.stdout)