synapse-hub 3.0.0 → 3.0.1

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 (65) hide show
  1. package/.agents/rules/communication-style.md +4 -0
  2. package/.agents/rules/context-engineering.md +1 -0
  3. package/.agents/rules/git-push-protocol.md +17 -0
  4. package/.agents/skills/agentless-qa-repair/examples/three_phase_repair_workflow.md +40 -0
  5. package/.agents/skills/antigravity-test-sentinel/examples/sprint_dod_enforcement.md +47 -0
  6. package/.agents/skills/auth-session-and-cookie-manager/examples/multi_role_auth_matrix.md +70 -0
  7. package/.agents/skills/code-refactoring-refactor-clean/examples/refactored_playwright_pom.ts +52 -0
  8. package/.agents/skills/failure-autopsy-and-healing/examples/closed_loop_healing_walkthrough.md +48 -0
  9. package/.agents/skills/lean-impact-scoreboard/examples/sample_scoreboard.md +1 -1
  10. package/.agents/skills/mipro-prompt-compiler/examples/dspy_assertion_optimizer_case.md +46 -0
  11. package/.agents/skills/persistent-planning-files/SKILL.md +1 -1
  12. package/.agents/skills/qa-aci-interface/examples/surgical_code_window_walkthrough.md +40 -0
  13. package/.agents/skills/review-spec/examples/pocock_grilling_decision_rubric.md +40 -0
  14. package/.agents/skills/skillspector-security-guard/examples/audit_threat_vectors_report.json +35 -0
  15. package/.agents/skills/surgical-coding-principles/SKILL.md +1 -1
  16. package/.agents/skills/tot-exploratory-testing/examples/payment_gateway_tot_tree.json +62 -0
  17. package/.agents/skills/verification-before-completion/SKILL.md +2 -2
  18. package/.agents/skills/verification-before-completion/examples/evidence_log_example.md +2 -2
  19. package/.agents/skills/web-agent-gym-benchmarking/examples/enterprise_tasks_suite.json +38 -0
  20. package/.agents/skills/web-vitals-and-performance-profiler/examples/playwright_cwv_profiler_fixture.ts +61 -0
  21. package/AGENTS.md +6 -4
  22. package/CLAUDE.md +1 -0
  23. package/README.en.md +8 -8
  24. package/README.md +3 -3
  25. package/action.yml +1 -1
  26. package/bin/agent-qa.js +2 -2
  27. package/docs/ARCHITECTURE.en.md +192 -0
  28. package/docs/ARCHITECTURE.md +127 -126
  29. package/docs/CI_CD_INTEGRATION.en.md +96 -0
  30. package/docs/CI_CD_INTEGRATION.md +24 -22
  31. package/docs/CLI_REFERENCE.en.md +174 -0
  32. package/docs/CLI_REFERENCE.md +120 -115
  33. package/docs/CONTRIBUTING.en.md +49 -0
  34. package/docs/CONTRIBUTING.md +25 -67
  35. package/docs/QUALITY_GATES.en.md +115 -0
  36. package/docs/QUALITY_GATES.md +82 -80
  37. package/docs/research/agent_skill_training_analysis.md +1 -1
  38. package/package.json +1 -1
  39. package/skills_manifest.json +1 -1
  40. package/synapse.config.json +1 -1
  41. package/templates/ai_bridges/CLAUDE.md +1 -0
  42. package/tools/__init__.py +1 -1
  43. package/tools/auto_heal_reflexion.py +8 -0
  44. package/tools/browser_helpers.js +2 -2
  45. package/tools/bump_version.py +15 -14
  46. package/tools/check_version_sync.py +24 -7
  47. package/tools/cli.py +3 -3
  48. package/tools/contract_validator.py +141 -0
  49. package/tools/dashboard_generator.py +11 -11
  50. package/tools/db_sandbox.py +22 -9
  51. package/tools/generate_allure_report.py +1 -1
  52. package/tools/infer_linter.py +135 -21
  53. package/tools/mcp_excel_server.py +1 -1
  54. package/tools/mcp_server.py +7 -4
  55. package/tools/micro_mutator.py +21 -3
  56. package/tools/reversible_sandbox.py +2 -2
  57. package/tools/senior_code_review.py +2 -1
  58. package/tools/skill_vault.py +82 -3
  59. package/tools/skillspector.py +5 -1
  60. package/tools/sync_agent_rules.py +1 -0
  61. package/tools/test_impact.py +2 -1
  62. package/tools/update_sentinel.py +3 -3
  63. package/tools/verify_all.py +2 -2
  64. package/tools/visual_diff_canvas.py +3 -2
  65. package/tools/welcome_page.py +1 -1
@@ -46,7 +46,7 @@ import gherkin_suite
46
46
  import api_fuzzer
47
47
 
48
48
  SERVER_NAME = "enterprise-agent-qa"
49
- SERVER_VERSION = "3.0.0"
49
+ SERVER_VERSION = "3.0.1"
50
50
 
51
51
  MCP_TOOLS = [
52
52
  {
@@ -473,13 +473,15 @@ def _handle_pull_alm_story(arguments: Dict[str, Any]) -> str:
473
473
 
474
474
  def _handle_report_alm_defect(arguments: Dict[str, Any]) -> str:
475
475
  bridge = tms_sync_bridge.TMSBridge()
476
+ raw_screenshot = arguments.get("screenshot_path", "")
477
+ safe_screenshot = _validate_safe_path(raw_screenshot) if raw_screenshot else ""
476
478
  ticket = bridge.report_defect(
477
479
  project_key=arguments["project_key"],
478
480
  test_case_id=arguments["test_case_id"],
479
481
  summary=arguments["summary"],
480
482
  error_message=arguments["error_message"],
481
483
  stack_trace=arguments.get("stack_trace", ""),
482
- screenshot_path=arguments.get("screenshot_path", "")
484
+ screenshot_path=safe_screenshot
483
485
  )
484
486
  return json.dumps(ticket, indent=2)
485
487
 
@@ -517,7 +519,8 @@ def _handle_convert_traffic_session(arguments: Dict[str, Any]) -> str:
517
519
 
518
520
  def _handle_resolve_visual_som_coordinates(arguments: Dict[str, Any]) -> str:
519
521
  helper = visual_som_helper.VisualSoMHelper()
520
- img = arguments.get("image_path", "screenshot.png")
522
+ raw_img = arguments.get("image_path", "screenshot.png")
523
+ img = _validate_safe_path(raw_img)
521
524
  lbl = arguments.get("target_label")
522
525
  mid = arguments.get("mark_id")
523
526
  marked_img, marks_map = helper.generate_som_overlay(img, [])
@@ -526,7 +529,7 @@ def _handle_resolve_visual_som_coordinates(arguments: Dict[str, Any]) -> str:
526
529
 
527
530
 
528
531
  def _handle_compile_gherkin_suite(arguments: Dict[str, Any]) -> str:
529
- target = arguments["target"]
532
+ target = _validate_safe_path(arguments["target"])
530
533
  res = gherkin_suite.compile_feature(target) if os.path.isfile(target) else gherkin_suite.run(target)
531
534
  if isinstance(res, int):
532
535
  return json.dumps({"exit_code": res, "detail": "see reports/gherkin_suite_report.json"}, indent=2)
@@ -22,6 +22,7 @@ import os
22
22
  import re
23
23
  import sys
24
24
  import json
25
+ import ast
25
26
  import shlex
26
27
  import html as html_lib
27
28
  import argparse
@@ -59,11 +60,12 @@ MUTATION_RULES = [
59
60
  (r"\breturn\s+true\b", "return false", "Return Value Inversion (return true -> return false)"),
60
61
  (r"\breturn\s+false\b", "return true", "Return Value Inversion (return false -> return true)"),
61
62
  (r"\breturn\s+0\b", "return -1", "Return Value Shift (return 0 -> return -1)"),
63
+ (r"\s\?\?\s", " || ", "Nullish Coalescing Inversion (?? -> ||)"),
62
64
  ]
63
65
 
64
66
  # Action statement pattern for Statement Deletion Mutants
65
67
  ACTION_STATEMENT_PATTERN = re.compile(
66
- r'\.(click|submit|save|update|delete|send|fill|press|type|execute)\s*\(',
68
+ r'\.(click|submit|save|update|delete|send|fill|press|type|execute|check|uncheck|hover|dragTo|selectOption|clear|focus|setInputFiles)\s*\(',
67
69
  re.IGNORECASE
68
70
  )
69
71
 
@@ -74,7 +76,12 @@ ASSERTION_PATTERN = re.compile(
74
76
 
75
77
  # Tautological / fake assertions that do not test application logic
76
78
  TAUTOLOGY_PATTERN = re.compile(
77
- 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*\))',
79
+ r'(?:assert\s+(?:true|1\s*==\s*1)\b'
80
+ r'|\bassert\s+([a-zA-Z0-9_\.]+(?:\([^)]*\))?)\s*==\s*\1\b'
81
+ r'|\bassert\s+(?:len\s*\([^)]*\)|[a-zA-Z0-9_\.]+\.(?:length|size\(\)))\s*(?:>=\s*0|>\s*-(?:1|0))\b'
82
+ r'|assertEquals\s*\(\s*([a-zA-Z_]\w*)\s*,\s*\1\s*\)'
83
+ r'|expect\s*\(\s*([a-zA-Z_]\w*)\s*\)\.toBe\s*\(\s*\1\s*\)'
84
+ r'|assertTrue\s*\(\s*true\s*\))',
78
85
  re.IGNORECASE
79
86
  )
80
87
 
@@ -301,6 +308,10 @@ def evaluate_mutants_statically(code: str, mutants: List[Dict[str, Any]]) -> Tup
301
308
 
302
309
  # Case 1: Mutation directly in an assertion statement
303
310
  if ASSERTION_PATTERN.search(snippet):
311
+ if TAUTOLOGY_PATTERN.search(snippet):
312
+ m["status"] = "SURVIVED"
313
+ m["reason"] = "Mutant in tautological/dummy assertion - cannot kill mutant without genuine assertion logic"
314
+ continue
304
315
  m["status"] = "KILLED"
305
316
  m["reason"] = "Direct assertion mutation caught by verification condition"
306
317
  killed_count += 1
@@ -324,6 +335,13 @@ def evaluate_mutants_statically(code: str, mutants: List[Dict[str, Any]]) -> Tup
324
335
  tgt = asgn.group(1)
325
336
  if any(re.search(rf'\b{re.escape(t)}\b', l_code) for t in dataflow_tokens):
326
337
  dataflow_tokens.add(tgt)
338
+ destruct = re.match(r'^\s*(?:(?:const|let|var|val)\s+)?[\{\(]([^\}\)]+)[\}\)]\s*=', l_code)
339
+ if destruct:
340
+ vars_in_destruct = [v.strip().split(':')[0].strip() for v in destruct.group(1).split(',') if v.strip()]
341
+ if any(re.search(rf'\b{re.escape(t)}\b', l_code) for t in dataflow_tokens):
342
+ for v in vars_in_destruct:
343
+ if re.match(r'^[a-zA-Z_][a-zA-Z0-9_]*$', v):
344
+ dataflow_tokens.add(v)
327
345
 
328
346
  # Check if any assertion references the variables mutated in this logic branch or downstream dataflow
329
347
  has_variable_link = any(any(re.search(rf'\b{re.escape(tok)}\b', a_text) for tok in dataflow_tokens) for _, a_text in downstream)
@@ -633,7 +651,7 @@ def main():
633
651
  print(format_junit(report))
634
652
  else:
635
653
  mode_tag = "LIVE DYNAMIC RUNNER" if report.get("mode") == "DYNAMIC" else "STATIC HEURISTIC"
636
- mode_desc = f"LIVE DYNAMIC RUNNER (cmd: '{args.test_cmd}')" if report.get("mode") == "DYNAMIC" else "STATIC HEURISTIC (fast 0.1s AST; pass --test-cmd for dynamic run)"
654
+ mode_desc = f"LIVE DYNAMIC RUNNER (cmd: '{args.test_cmd}')" if report.get("mode") == "DYNAMIC" else "STATIC HEURISTIC (fast heuristic assertion sensitivity scan; pass --test-cmd for dynamic run)"
637
655
  print("\n======================================================================")
638
656
  print(f" ASSERTION SENSITIVITY & SYNTACTIC MUTATION SENTINEL [{mode_tag}] ")
639
657
  print("======================================================================")
@@ -60,7 +60,7 @@ class ExecutionSandbox:
60
60
  """Executes a forward shell command and immediately records its reversible undo counterpart."""
61
61
  try:
62
62
  args = shlex.split(forward_cmd) if isinstance(forward_cmd, str) else forward_cmd
63
- res = subprocess.run(args, text=True, capture_output=True)
63
+ res = subprocess.run(args, text=True, capture_output=True, timeout=60.0)
64
64
  if res.returncode == 0:
65
65
  self.record_mutation(action_type, target_resource, undo_cmd, {"forward_cmd": forward_cmd})
66
66
  return True
@@ -97,7 +97,7 @@ class ExecutionSandbox:
97
97
  elif cmd:
98
98
  try:
99
99
  args = shlex.split(cmd) if isinstance(cmd, str) else cmd
100
- res = subprocess.run(args, text=True, capture_output=True)
100
+ res = subprocess.run(args, text=True, capture_output=True, timeout=60.0)
101
101
  success = (res.returncode == 0)
102
102
  if not success:
103
103
  err_msg = res.stderr.strip()
@@ -80,7 +80,8 @@ def get_git_diff(repo_dir: str, target: str = "uncommitted") -> str:
80
80
  text=True,
81
81
  encoding="utf-8",
82
82
  errors="ignore",
83
- check=True
83
+ check=True,
84
+ timeout=30.0
84
85
  )
85
86
  return res.stdout
86
87
  except Exception:
@@ -50,11 +50,25 @@ QA_SYNONYMS: Dict[str, Set[str]] = {
50
50
  "dropdown": {"select", "combobox", "option", "menu", "picker", "choice"},
51
51
  "invoice": {"billing", "order", "receipt", "payment", "charge", "reconcile"},
52
52
  "cart": {"checkout", "basket", "purchase", "item", "order"},
53
- "upload": {"attachment", "file", "import", "choose-file", "document"},
53
+ "upload": {"attachment", "file", "import", "choose-file", "document", "dropzone"},
54
+ "export": {"export", "download", "csv", "excel", "pdf", "report"},
55
+ "toast": {"toast", "notification", "snackbar", "alert", "message", "banner", "popup"},
56
+ "spinner": {"spinner", "loader", "loading", "shimmer", "skeleton", "progress", "wait"},
57
+ "drawer": {"drawer", "sidebar", "panel", "modal", "aside", "sheet"},
58
+ "tab": {"tab", "tabs", "tabpanel", "nav", "segment"},
54
59
  "wait": {"detach", "detached", "sleep", "loading", "spinner", "ready", "networkidle", "smartwait"},
55
60
  }
56
61
 
57
62
 
63
+ def split_identifier(identifier: str) -> List[str]:
64
+ """Splits camelCase, PascalCase, and snake_case tokens into lowercase sub-tokens."""
65
+ tokens = re.sub(r'([A-Z]+)', r' \1', identifier).split()
66
+ sub_tokens = []
67
+ for t in tokens:
68
+ sub_tokens.extend(re.split(r'[_\\-]+', t))
69
+ return [t.lower() for t in sub_tokens if len(t) >= 2]
70
+
71
+
58
72
  def _expand_tokens_with_synonyms(tokens: List[str]) -> Set[str]:
59
73
  """Expands query tokens using the QA domain synonym clusters."""
60
74
  expanded = set(tokens)
@@ -185,7 +199,13 @@ class SkillVault:
185
199
  Queries verified primitives using intelligent fuzzy semantic matching:
186
200
  combines exact tokens, synonym expansion, n-gram substring match, and SequenceMatcher similarity.
187
201
  """
188
- raw_tokens = [t.lower() for t in re.findall(r'[a-zA-Z0-9_-]+', query) if len(t) >= 2]
202
+ words = re.findall(r'[a-zA-Z0-9_-]+', query)
203
+ raw_tokens = []
204
+ for w in words:
205
+ sub = split_identifier(w)
206
+ raw_tokens.extend(sub if sub else [w.lower()])
207
+ raw_tokens = list(dict.fromkeys(raw_tokens))
208
+
189
209
  if not raw_tokens and not tag:
190
210
  return self.list_primitives(limit=top_k)
191
211
 
@@ -203,7 +223,12 @@ class SkillVault:
203
223
  scored = []
204
224
  for r in rows:
205
225
  corpus_text = f"{r['page_class']} {r['method_signature']} {r['semantic_docstring'] or ''} {r['tags'] or ''}".lower()
206
- corpus_tokens = [t.lower() for t in re.findall(r'[a-zA-Z0-9_-]+', corpus_text) if len(t) >= 2]
226
+ corpus_words = re.findall(r'[a-zA-Z0-9_-]+', corpus_text)
227
+ corpus_tokens = []
228
+ for cw in corpus_words:
229
+ sub = split_identifier(cw)
230
+ corpus_tokens.extend(sub if sub else [cw.lower()])
231
+ corpus_tokens = list(dict.fromkeys(corpus_tokens))
207
232
 
208
233
  # Calculate individual query token matches
209
234
  token_scores = [_calculate_token_match_score(qt, corpus_tokens) for qt in raw_tokens]
@@ -229,6 +254,54 @@ class SkillVault:
229
254
  scored.sort(key=lambda x: (x["relevance_score"], x["mutation_score"]), reverse=True)
230
255
  return scored[:top_k]
231
256
 
257
+ def harvest_page_classes(self, dir_path: str) -> int:
258
+ """
259
+ Scans a directory for Page Classes across TS, JS, Python, and Groovy,
260
+ extracts method signatures, and registers them into SkillVault.
261
+ """
262
+ if not os.path.isdir(dir_path):
263
+ return 0
264
+ count = 0
265
+ for root, _, files in os.walk(dir_path):
266
+ for f in files:
267
+ ext = os.path.splitext(f)[1].lower()
268
+ if ext not in (".py", ".ts", ".js", ".groovy", ".java"):
269
+ continue
270
+ file_path = os.path.join(root, f)
271
+ try:
272
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as fp:
273
+ content = fp.read()
274
+ except OSError:
275
+ continue
276
+
277
+ page_class = os.path.splitext(f)[0]
278
+ method_matches = re.finditer(r'(?:def\s+([a-zA-Z_]\w*)\s*\(([^)]*)\)|(?:async\s+)?([a-zA-Z_]\w*)\s*\(([^)]*)\)\s*(?::\s*[^\{]+)?\s*\{)', content)
279
+ for mm in method_matches:
280
+ m_name = mm.group(1) or mm.group(3)
281
+ m_args = mm.group(2) if mm.group(1) else mm.group(4)
282
+ if not m_name or m_name.startswith("_") or m_name in ("constructor", "constructor__"):
283
+ continue
284
+ skill_id = f"SKILL_{page_class.upper()}_{m_name.upper()}"
285
+ sig = f"{m_name}({m_args.strip() if m_args else ''})"
286
+ doc_m = re.search(rf'{re.escape(m_name)}[^\n]*\n\s*[\'\""""/]+([^\'\""""\n*]+)', content)
287
+ doc = doc_m.group(1).strip() if doc_m else f"Page method for {m_name} on {page_class}"
288
+ tags = ",".join(split_identifier(page_class) + split_identifier(m_name))
289
+
290
+ prim = PageClassPrimitive(
291
+ skill_id=skill_id,
292
+ page_class=page_class,
293
+ method_signature=sig,
294
+ method_code=f"// Harvested from {f}\n{sig}",
295
+ locator_tier=1,
296
+ semantic_docstring=doc,
297
+ verified_on_url=f"local://{f}",
298
+ mutation_score=85.0,
299
+ tags=tags
300
+ )
301
+ if self.register_primitive(prim):
302
+ count += 1
303
+ return count
304
+
232
305
  def list_primitives(self, limit: int = 50) -> List[Dict[str, Any]]:
233
306
  with self._get_connection() as conn:
234
307
  rows = conn.execute("SELECT * FROM page_primitives ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
@@ -239,12 +312,18 @@ def main():
239
312
  parser = argparse.ArgumentParser(description="SkillVault Lifelong POM Library")
240
313
  parser.add_argument("--query", default=None, help="Query verified primitives by keyword (fuzzy semantic search)")
241
314
  parser.add_argument("--tag", default=None, help="Filter primitives by domain tag (e.g. auth, table, invoice)")
315
+ parser.add_argument("--harvest", default=None, help="Directory path to harvest Page Classes from")
242
316
  parser.add_argument("--list", action="store_true", help="List all stored primitives")
243
317
  parser.add_argument("--seed-samples", action="store_true", help="Seed initial verified POM primitives")
244
318
  args = parser.parse_args()
245
319
 
246
320
  vault = SkillVault()
247
321
 
322
+ if args.harvest:
323
+ num = vault.harvest_page_classes(args.harvest)
324
+ print(f"✅ Harvested {num} Page Class primitives from '{args.harvest}'.")
325
+ return 0
326
+
248
327
  if args.seed_samples:
249
328
  samples = [
250
329
  PageClassPrimitive(
@@ -956,7 +956,11 @@ def scan_target(target_path: str) -> ScanReport:
956
956
  all_findings: List[Finding] = []
957
957
  scanned_count = 0
958
958
 
959
- SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache", "dist", "build"}
959
+ SKIP_DIRS = {
960
+ ".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache",
961
+ "dist", "build", "claude-ecosystem-repos", "graphify-out", ".synapse",
962
+ ".mypy_cache", ".ruff_cache", ".sandbox_snapshots", "reports"
963
+ }
960
964
  SKIP_FILES = {"skillspector.py"}
961
965
  SCANNABLE_EXTS = {".md", ".py", ".json", ".yaml", ".yml", ".sh", ".ps1", ".txt", ".ts", ".js"}
962
966
 
@@ -92,6 +92,7 @@ In accordance with the MIT License, the original copyright notice crediting **Th
92
92
  5. **Pragmatic Communication Protocol:**
93
93
  - Conversational responses, explanation, and telemetry MUST be in Vietnamese.
94
94
  - All codebase assets, test scripts, Page Classes, docstrings, and commits MUST remain strictly in English.
95
+ - English-First Document Ingestion: When reading bilingual docs (e.g. ARCHITECTURE.en.md vs ARCHITECTURE.md), always read the English version (*.en.md) to save 50–65% BPE tokens.
95
96
 
96
97
  6. **Lean Senior Dev Protocol (The 7-Rung Ladder & YAGNI):**
97
98
  - The best code is the code never written. Solve the whole problem with the least new code.
@@ -175,7 +175,8 @@ def get_git_modified_files(repo_root=None):
175
175
  stdout=subprocess.PIPE,
176
176
  stderr=subprocess.PIPE,
177
177
  text=True,
178
- check=True
178
+ check=True,
179
+ timeout=30.0
179
180
  )
180
181
  files = [line.strip() for line in res.stdout.splitlines() if line.strip()]
181
182
  return files
@@ -65,7 +65,7 @@ def get_local_version():
65
65
  try:
66
66
  with open(pkg_file, "r", encoding="utf-8") as f:
67
67
  data = json.load(f)
68
- return data.get("version", "3.0.0")
68
+ return data.get("version", "3.0.1")
69
69
  except (OSError, json.JSONDecodeError):
70
70
  data = {}
71
71
 
@@ -74,11 +74,11 @@ def get_local_version():
74
74
  try:
75
75
  with open(manifest_file, "r", encoding="utf-8") as f:
76
76
  data = json.load(f)
77
- return data.get("version", "3.0.0")
77
+ return data.get("version", "3.0.1")
78
78
  except (OSError, json.JSONDecodeError):
79
79
  data = {}
80
80
 
81
- return "3.0.0"
81
+ return "3.0.1"
82
82
 
83
83
 
84
84
  def fetch_remote_version():
@@ -296,7 +296,7 @@ def gate_3_clean_pom(repo_root, mode="AUTO"):
296
296
  print("[N/A] Hub Mode: Not Applicable (Clean POM rules apply to target test suites in Spokes).")
297
297
  return True
298
298
 
299
- test_dirs = ["Scripts", "tests", "test", "e2e", "specs", "cypress"]
299
+ test_dirs = ["tests"] if effective_mode == "HUB" else ["Scripts", "tests", "test", "e2e", "specs", "cypress"]
300
300
  test_files = []
301
301
  for td in test_dirs:
302
302
  d = os.path.join(repo_root, td)
@@ -708,7 +708,7 @@ def gate_7_infer_linter(repo_root, mode="AUTO"):
708
708
  except (ImportError, OSError) as err:
709
709
  print(f"[WARN] Failed to run check_version_sync: {err}")
710
710
 
711
- test_dirs = ["Scripts", "tests", "test", "e2e", "specs", "cypress"]
711
+ test_dirs = ["tests"] if effective_mode == "HUB" else ["Scripts", "tests", "test", "e2e", "specs", "cypress"]
712
712
  has_tests = False
713
713
  target_scan_dir = None
714
714
  for td in test_dirs:
@@ -55,11 +55,12 @@ def compare_canvas_images(
55
55
  """
56
56
  with Image.open(baseline_path) as img_base, Image.open(current_path) as img_curr:
57
57
  # Normalize dimensions and color mode
58
+ curr_image: Image.Image = img_curr
58
59
  if img_base.size != img_curr.size:
59
- img_curr = img_curr.resize(img_base.size, Image.Resampling.LANCZOS)
60
+ curr_image = img_curr.resize(img_base.size, Image.Resampling.LANCZOS)
60
61
 
61
62
  base_rgb = img_base.convert("RGB")
62
- curr_rgb = img_curr.convert("RGB")
63
+ curr_rgb = curr_image.convert("RGB")
63
64
 
64
65
  # C-level accelerated pixel difference via Pillow vectorization
65
66
  diff = ImageChops.difference(base_rgb, curr_rgb)
@@ -47,7 +47,7 @@ def get_hub_metadata() -> Dict[str, Any]:
47
47
  """Retrieves version and catalog count from skills_manifest.json."""
48
48
  manifest_path = os.path.join(HUB_ROOT, "skills_manifest.json")
49
49
  meta = {
50
- "version": "3.0.0",
50
+ "version": "3.0.1",
51
51
  "totalSkills": 42,
52
52
  "author": "Thế Vị (@TeeVee998)",
53
53
  "repo": "https://github.com/TeeVee998/Synapse-Hub"