0xray 2.1.1 → 2.1.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.
- package/.opencode/codex.codex +1 -1
- package/.opencode/commands/dependency-audit.md +3 -3
- package/.opencode/enforcer-config.json +2 -2
- package/AGENTS.md +3 -2
- package/README.md +12 -11
- package/dist/AGENTS.md +3 -2
- package/dist/CHANGELOG.md +26 -0
- package/dist/README.md +12 -11
- package/dist/agents/code-reviewer.js +1 -1
- package/dist/analytics/routing-refiner.js +1 -1
- package/dist/cli/index.js +11 -1
- package/dist/cli/server.js +3 -3
- package/dist/core/activity-logger.d.ts +2 -2
- package/dist/core/activity-logger.js +4 -4
- package/dist/core/boot-orchestrator.d.ts +1 -1
- package/dist/core/boot-orchestrator.js +13 -28
- package/dist/core/bridge.mjs +3 -3
- package/dist/core/codex-formatter.js +2 -2
- package/dist/core/codex-injector.d.ts +0 -1
- package/dist/core/codex-injector.js +2 -3
- package/dist/core/config-loader.d.ts +1 -1
- package/dist/core/config-loader.js +1 -1
- package/dist/core/config-paths.d.ts +0 -2
- package/dist/core/config-paths.js +7 -8
- package/dist/core/context-loader.d.ts +1 -1
- package/dist/core/context-loader.js +1 -1
- package/dist/core/errors.d.ts +3 -0
- package/dist/core/errors.js +10 -0
- package/dist/core/features-config.js +1 -1
- package/dist/core/framework-logger.d.ts +3 -3
- package/dist/core/framework-logger.js +17 -9
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +4 -2
- package/dist/core/logging-config.d.ts +2 -1
- package/dist/core/logging-config.js +7 -7
- package/dist/enforcement/loaders/codex-loader.js +1 -1
- package/dist/execution/opencode-cli-invoker.js +5 -5
- package/dist/governance/governance-service.js +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/inference/inference-cycle.d.ts +1 -1
- package/dist/inference/inference-cycle.js +10 -10
- package/dist/integrations/base/Integration.js +1 -1
- package/dist/integrations/base/registry.js +19 -19
- package/dist/integrations/grok/grok-cli.js +17 -17
- package/dist/integrations/grok/hooks/pre-tool-use.js +1 -1
- package/dist/integrations/hermes-agent/bridge.mjs +1 -1
- package/dist/integrations/openclaw/api-server.d.ts +0 -1
- package/dist/integrations/openclaw/api-server.js +7 -10
- package/dist/integrations/openclaw/client.d.ts +0 -1
- package/dist/integrations/openclaw/client.js +22 -24
- package/dist/integrations/openclaw/hooks/xray-hooks.d.ts +0 -1
- package/dist/integrations/openclaw/hooks/xray-hooks.js +17 -18
- package/dist/integrations/plugins/plugin-registry.js +5 -5
- package/dist/mcps/architect-tools.server.d.ts +2 -4
- package/dist/mcps/architect-tools.server.js +112 -195
- package/dist/mcps/auto-format.server.d.ts +2 -4
- package/dist/mcps/auto-format.server.js +49 -95
- package/dist/mcps/boot-orchestrator.server.d.ts +2 -4
- package/dist/mcps/boot-orchestrator.server.js +73 -105
- package/dist/mcps/config/server-config-registry.js +3 -3
- package/dist/mcps/enforcer-tools.server.d.ts +2 -4
- package/dist/mcps/enforcer-tools.server.js +202 -285
- package/dist/mcps/estimation.server.d.ts +2 -4
- package/dist/mcps/estimation.server.js +63 -107
- package/dist/mcps/framework-compliance-audit.server.d.ts +2 -4
- package/dist/mcps/framework-compliance-audit.server.js +53 -82
- package/dist/mcps/framework-help.server.d.ts +2 -4
- package/dist/mcps/framework-help.server.js +63 -101
- package/dist/mcps/governance.server.js +2 -2
- package/dist/mcps/knowledge-skills/api-design.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/api-design.server.js +35 -67
- package/dist/mcps/knowledge-skills/architecture-patterns.server.d.ts +2 -10
- package/dist/mcps/knowledge-skills/architecture-patterns.server.js +35 -74
- package/dist/mcps/knowledge-skills/bug-triage-specialist.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/bug-triage-specialist.server.js +143 -162
- package/dist/mcps/knowledge-skills/code-analyzer.server.d.ts +3 -4
- package/dist/mcps/knowledge-skills/code-analyzer.server.js +20 -45
- package/dist/mcps/knowledge-skills/code-review.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/code-review.server.js +109 -143
- package/dist/mcps/knowledge-skills/content-creator.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/content-creator.server.js +205 -226
- package/dist/mcps/knowledge-skills/database-design.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/database-design.server.js +117 -151
- package/dist/mcps/knowledge-skills/devops-deployment.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/devops-deployment.server.js +71 -160
- package/dist/mcps/knowledge-skills/git-workflow.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/git-workflow.server.js +36 -68
- package/dist/mcps/knowledge-skills/growth-strategist.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/growth-strategist.server.js +303 -324
- package/dist/mcps/knowledge-skills/log-monitor.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/log-monitor.server.js +141 -160
- package/dist/mcps/knowledge-skills/mobile-development.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/mobile-development.server.js +92 -209
- package/dist/mcps/knowledge-skills/multimodal-looker.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/multimodal-looker.server.js +123 -159
- package/dist/mcps/knowledge-skills/performance-optimization.server.d.ts +2 -5
- package/dist/mcps/knowledge-skills/performance-optimization.server.js +155 -296
- package/dist/mcps/knowledge-skills/project-analysis.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/project-analysis.server.js +75 -226
- package/dist/mcps/knowledge-skills/refactoring-strategies.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/refactoring-strategies.server.js +63 -156
- package/dist/mcps/knowledge-skills/security-audit.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/security-audit.server.js +102 -136
- package/dist/mcps/knowledge-skills/seo-consultant.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/seo-consultant.server.js +80 -203
- package/dist/mcps/knowledge-skills/session-management.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/session-management.server.js +50 -203
- package/dist/mcps/knowledge-skills/skill-invocation.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/skill-invocation.server.js +168 -347
- package/dist/mcps/knowledge-skills/strategist.server.d.ts +2 -11
- package/dist/mcps/knowledge-skills/strategist.server.js +72 -122
- package/dist/mcps/knowledge-skills/tech-writer.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/tech-writer.server.js +87 -300
- package/dist/mcps/knowledge-skills/testing-best-practices.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/testing-best-practices.server.js +147 -182
- package/dist/mcps/knowledge-skills/testing-strategy.server.d.ts +2 -4
- package/dist/mcps/knowledge-skills/testing-strategy.server.js +78 -153
- package/dist/mcps/knowledge-skills/ui-ux-design.server.d.ts +2 -5
- package/dist/mcps/knowledge-skills/ui-ux-design.server.js +90 -399
- package/dist/mcps/lint.server.d.ts +2 -4
- package/dist/mcps/lint.server.js +51 -92
- package/dist/mcps/mcp-client.js +2 -2
- package/dist/mcps/model-health-check.server.d.ts +2 -4
- package/dist/mcps/model-health-check.server.js +32 -60
- package/dist/mcps/performance-analysis.server.d.ts +2 -4
- package/dist/mcps/performance-analysis.server.js +57 -88
- package/dist/mcps/processor-pipeline.server.d.ts +2 -4
- package/dist/mcps/processor-pipeline.server.js +69 -100
- package/dist/mcps/registry.json +1 -1
- package/dist/mcps/researcher.server.d.ts +3 -5
- package/dist/mcps/researcher.server.js +81 -154
- package/dist/mcps/security-scan.server.d.ts +2 -4
- package/dist/mcps/security-scan.server.js +54 -96
- package/dist/mcps/shared/knowledge-skill-base.d.ts +14 -0
- package/dist/mcps/shared/knowledge-skill-base.js +45 -0
- package/dist/{security → mcps/shared}/security-scanner.js +1 -1
- package/dist/mcps/state-manager.server.d.ts +2 -4
- package/dist/mcps/state-manager.server.js +115 -160
- package/dist/orchestrator/orchestrator.d.ts +1 -1
- package/dist/orchestrator/orchestrator.js +1 -1
- package/dist/orchestrator/universal-registry-bridge.js +1 -1
- package/dist/plugin/xray-codex-injection.d.ts +1 -1
- package/dist/plugin/xray-codex-injection.js +1 -1
- package/dist/postprocessor/PostProcessor.d.ts +4 -44
- package/dist/postprocessor/PostProcessor.js +39 -553
- package/dist/postprocessor/analysis/CodeChangeAnalyzer.d.ts +11 -0
- package/dist/postprocessor/analysis/CodeChangeAnalyzer.js +50 -0
- package/dist/postprocessor/compliance/ArchitecturalComplianceChecker.d.ts +11 -0
- package/dist/postprocessor/compliance/ArchitecturalComplianceChecker.js +356 -0
- package/dist/postprocessor/config/ProcessorConfigLoader.d.ts +44 -0
- package/dist/postprocessor/config/ProcessorConfigLoader.js +21 -0
- package/dist/postprocessor/reporting/PostProcessorReporter.d.ts +19 -0
- package/dist/postprocessor/reporting/PostProcessorReporter.js +96 -0
- package/dist/postprocessor/triggers/GitHookTrigger.js +11 -11
- package/dist/processors/implementations/refactoring-logging-processor-wrapper.d.ts +32 -0
- package/dist/processors/implementations/refactoring-logging-processor-wrapper.js +95 -1
- package/dist/processors/processor-manager.js +346 -314
- package/dist/reporting/report-formatter.js +1 -1
- package/dist/security/security-hardener.d.ts +69 -2
- package/dist/security/security-hardener.js +129 -1
- package/dist/skills/registry.json +1 -1
- package/dist/state/index.d.ts +3 -5
- package/dist/state/index.js +1 -7
- package/dist/state/state-manager.d.ts +1 -1
- package/dist/state/state-manager.js +2 -3
- package/package.json +14 -10
- package/scripts/node/setup.cjs +32 -0
- package/scripts/node/universal-version-manager.js +11 -11
- package/src/mcps/architect-tools.server.ts +112 -215
- package/src/mcps/auto-format.server.ts +50 -110
- package/src/mcps/boot-orchestrator.server.ts +75 -121
- package/src/mcps/config/__tests__/server-config-registry.test.ts +21 -12
- package/src/mcps/config/server-config-registry.ts +3 -3
- package/src/mcps/enforcer-tools.server.ts +212 -310
- package/src/mcps/estimation.server.ts +62 -122
- package/src/mcps/framework-compliance-audit.server.ts +52 -97
- package/src/mcps/framework-help.server.ts +64 -114
- package/src/mcps/governance.server.ts +2 -2
- package/src/mcps/knowledge-skills/api-design.server.ts +32 -77
- package/src/mcps/knowledge-skills/architecture-patterns.server.ts +31 -87
- package/src/mcps/knowledge-skills/bug-triage-specialist.server.ts +165 -193
- package/src/mcps/knowledge-skills/code-analyzer.server.ts +20 -55
- package/src/mcps/knowledge-skills/code-review.server.ts +114 -161
- package/src/mcps/knowledge-skills/content-creator.server.ts +218 -255
- package/src/mcps/knowledge-skills/database-design.server.ts +118 -165
- package/src/mcps/knowledge-skills/devops-deployment.server.ts +67 -172
- package/src/mcps/knowledge-skills/git-workflow.server.ts +32 -77
- package/src/mcps/knowledge-skills/growth-strategist.server.ts +324 -361
- package/src/mcps/knowledge-skills/log-monitor.server.ts +160 -187
- package/src/mcps/knowledge-skills/mobile-development.server.ts +89 -223
- package/src/mcps/knowledge-skills/multimodal-looker.server.ts +128 -175
- package/src/mcps/knowledge-skills/performance-optimization.server.ts +156 -329
- package/src/mcps/knowledge-skills/project-analysis.server.ts +72 -248
- package/src/mcps/knowledge-skills/refactoring-strategies.server.ts +59 -171
- package/src/mcps/knowledge-skills/security-audit.server.ts +104 -151
- package/src/mcps/knowledge-skills/seo-consultant.server.ts +80 -220
- package/src/mcps/knowledge-skills/session-management.server.ts +51 -232
- package/src/mcps/knowledge-skills/skill-invocation.server.ts +165 -372
- package/src/mcps/knowledge-skills/strategist.server.ts +72 -143
- package/src/mcps/knowledge-skills/tech-writer.server.ts +85 -350
- package/src/mcps/knowledge-skills/testing-best-practices.server.ts +146 -195
- package/src/mcps/knowledge-skills/testing-strategy.server.ts +75 -161
- package/src/mcps/knowledge-skills/ui-ux-design.server.ts +93 -487
- package/src/mcps/lint.server.ts +53 -107
- package/src/mcps/mcp-client.ts +2 -2
- package/src/mcps/model-health-check.server.ts +34 -71
- package/src/mcps/performance-analysis.server.ts +60 -104
- package/src/mcps/processor-pipeline.server.ts +72 -110
- package/src/mcps/registry.json +1 -1
- package/src/mcps/researcher.server.ts +88 -177
- package/src/mcps/security-scan.server.ts +55 -104
- package/src/mcps/shared/knowledge-skill-base.ts +62 -0
- package/src/mcps/shared/prompt-security-validator.ts +199 -0
- package/src/mcps/shared/security-scanner.ts +599 -0
- package/src/mcps/state-manager.server.ts +117 -175
- package/src/opencode/codex.codex +1 -1
- package/src/opencode/commands/dependency-audit.md +3 -3
- package/src/opencode/enforcer-config.json +2 -2
- package/src/skills/registry.json +1 -1
- package/xray/agents_template.md +109 -0
- package/xray/codex.json +598 -0
- package/xray/config.json +26 -0
- package/xray/features.json +132 -0
- package/xray/integrations.json +23 -0
- package/xray/routing-mappings.json +752 -0
- package/xray/workflow_state.json +28 -0
- package/dist/integrations/hermes-agent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/dist/integrations/hermes-agent/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc +0 -0
- package/dist/integrations/hermes-agent/__pycache__/schemas.cpython-313.pyc +0 -0
- package/dist/integrations/hermes-agent/__pycache__/test_plugin.cpython-313-pytest-9.0.2.pyc +0 -0
- package/dist/integrations/hermes-agent/__pycache__/test_plugin.cpython-313.pyc +0 -0
- package/dist/integrations/hermes-agent/__pycache__/tools.cpython-313.pyc +0 -0
- package/dist/integrations/hermes-agent/conftest.py +0 -14
- package/dist/integrations/hermes-agent/test_plugin.py +0 -1103
- package/dist/processors/implementations/refactoring-logging-processor.d.ts +0 -31
- package/dist/processors/implementations/refactoring-logging-processor.js +0 -96
- package/dist/processors/implementations/session-capture-processor.d.ts +0 -14
- package/dist/processors/implementations/session-capture-processor.js +0 -37
- package/dist/scripts/activate-kernel-pipeline.d.ts +0 -7
- package/dist/scripts/activate-kernel-pipeline.js +0 -101
- package/dist/security/index.d.ts +0 -13
- package/dist/security/index.js +0 -13
- package/dist/security/security-agent-coordinator.d.ts +0 -72
- package/dist/security/security-agent-coordinator.js +0 -204
- package/dist/security/security-auditor.d.ts +0 -56
- package/dist/security/security-auditor.js +0 -584
- package/dist/security/security-hardening-system.d.ts +0 -239
- package/dist/security/security-hardening-system.js +0 -727
- package/dist/security/security-orchestration-layer.d.ts +0 -119
- package/dist/security/security-orchestration-layer.js +0 -496
- /package/dist/{security → mcps/shared}/prompt-security-validator.d.ts +0 -0
- /package/dist/{security → mcps/shared}/prompt-security-validator.js +0 -0
- /package/dist/{security → mcps/shared}/security-scanner.d.ts +0 -0
|
@@ -1,1103 +0,0 @@
|
|
|
1
|
-
"""Comprehensive tests for the 0xRay Hermes Plugin v2.
|
|
2
|
-
|
|
3
|
-
Tests all 3 tools, both hooks, the slash command, bridge integration,
|
|
4
|
-
logging to disk, and the full register() wiring.
|
|
5
|
-
|
|
6
|
-
v2 changes from v1:
|
|
7
|
-
- Hooks now pipe through Node.js bridge for real framework integration
|
|
8
|
-
- Tool events logged to disk (activity.log, plugin-tool-events.log)
|
|
9
|
-
- Tools use bridge first, fall back to CLI
|
|
10
|
-
- Session stats track quality gate runs, processor runs, bridge calls
|
|
11
|
-
- No more in-memory _TOOL_LOG — everything persists to disk
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
import json
|
|
15
|
-
import subprocess
|
|
16
|
-
import os
|
|
17
|
-
import sys
|
|
18
|
-
import tempfile
|
|
19
|
-
import unittest
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
from unittest.mock import patch, MagicMock, call
|
|
22
|
-
import logging
|
|
23
|
-
import importlib
|
|
24
|
-
import types
|
|
25
|
-
|
|
26
|
-
# ── Path setup ────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
PLUGIN_DIR = str(Path(__file__).resolve().parent)
|
|
29
|
-
sys.path.insert(0, PLUGIN_DIR)
|
|
30
|
-
|
|
31
|
-
# Force reimport
|
|
32
|
-
for mod in list(sys.modules):
|
|
33
|
-
if "xray" in mod and ("schemas" in mod or "tools" in mod or "plugin" in mod):
|
|
34
|
-
del sys.modules[mod]
|
|
35
|
-
|
|
36
|
-
schemas = importlib.import_module("schemas")
|
|
37
|
-
tools_mod = importlib.import_module("tools")
|
|
38
|
-
|
|
39
|
-
# Create a fake package for __init__.py execution
|
|
40
|
-
pkg = types.ModuleType("xray_hermes_pkg")
|
|
41
|
-
pkg.__path__ = [PLUGIN_DIR]
|
|
42
|
-
pkg.__dict__["schemas"] = schemas
|
|
43
|
-
pkg.__dict__["tools"] = tools_mod
|
|
44
|
-
sys.modules["xray_hermes_pkg"] = pkg
|
|
45
|
-
|
|
46
|
-
init_path = os.path.join(PLUGIN_DIR, "__init__.py")
|
|
47
|
-
with open(init_path) as f:
|
|
48
|
-
init_code = f.read()
|
|
49
|
-
init_code = init_code.replace("from . import schemas, tools", "import schemas, tools")
|
|
50
|
-
# Provide __file__ since exec() loses it
|
|
51
|
-
pkg.__dict__["__file__"] = init_path
|
|
52
|
-
exec(compile(init_code, init_path, "exec"), pkg.__dict__)
|
|
53
|
-
|
|
54
|
-
pi = pkg # plugin init module
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class TestSchemas(unittest.TestCase):
|
|
58
|
-
def test_validate_schema_has_required_fields(self):
|
|
59
|
-
s = schemas.XRAY_VALIDATE
|
|
60
|
-
self.assertEqual(s["name"], "xray_validate")
|
|
61
|
-
params = s["parameters"]
|
|
62
|
-
self.assertEqual(params["type"], "object")
|
|
63
|
-
self.assertIn("files", params["properties"])
|
|
64
|
-
self.assertIn("operation", params["properties"])
|
|
65
|
-
self.assertIn("files", params["required"])
|
|
66
|
-
self.assertEqual(params["properties"]["files"]["type"], "array")
|
|
67
|
-
|
|
68
|
-
def test_codex_check_schema(self):
|
|
69
|
-
s = schemas.XRAY_CODEX_CHECK
|
|
70
|
-
self.assertEqual(s["name"], "xray_codex_check")
|
|
71
|
-
fa = s["parameters"]["properties"]["focus_areas"]
|
|
72
|
-
self.assertIn("enum", fa["items"])
|
|
73
|
-
self.assertIn("error-handling", fa["items"]["enum"])
|
|
74
|
-
|
|
75
|
-
def test_health_schema_no_required(self):
|
|
76
|
-
s = schemas.XRAY_HEALTH
|
|
77
|
-
self.assertEqual(len(s["parameters"].get("required", [])), 0)
|
|
78
|
-
|
|
79
|
-
def test_descriptions_non_empty(self):
|
|
80
|
-
for name, schema in [("v", schemas.XRAY_VALIDATE), ("c", schemas.XRAY_CODEX_CHECK), ("h", schemas.XRAY_HEALTH)]:
|
|
81
|
-
self.assertTrue(len(schema["description"]) > 20, f"{name}")
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
class TestRunXrayHelper(unittest.TestCase):
|
|
85
|
-
"""Test the CLI fallback helper (still exists for bridge-less environments)."""
|
|
86
|
-
|
|
87
|
-
def test_successful_command(self):
|
|
88
|
-
with patch("subprocess.run") as m:
|
|
89
|
-
m.return_value = MagicMock(returncode=0, stdout="all good", stderr="")
|
|
90
|
-
r = json.loads(tools_mod._run_xray(["health"]))
|
|
91
|
-
self.assertEqual(r["status"], "ok")
|
|
92
|
-
self.assertEqual(m.call_args[0][0], ["npx", "0xray", "health"])
|
|
93
|
-
|
|
94
|
-
def test_command_failure(self):
|
|
95
|
-
with patch("subprocess.run") as m:
|
|
96
|
-
m.return_value = MagicMock(returncode=1, stdout="", stderr="broke")
|
|
97
|
-
r = json.loads(tools_mod._run_xray(["validate"]))
|
|
98
|
-
self.assertEqual(r["status"], "error")
|
|
99
|
-
|
|
100
|
-
def test_file_not_found(self):
|
|
101
|
-
with patch("subprocess.run", side_effect=FileNotFoundError):
|
|
102
|
-
r = json.loads(tools_mod._run_xray(["health"]))
|
|
103
|
-
self.assertIn("not found", r["error"])
|
|
104
|
-
|
|
105
|
-
def test_timeout(self):
|
|
106
|
-
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("c", 30)):
|
|
107
|
-
r = json.loads(tools_mod._run_xray(["health"], timeout=15))
|
|
108
|
-
self.assertIn("15s", r["error"])
|
|
109
|
-
|
|
110
|
-
def test_custom_timeout(self):
|
|
111
|
-
with patch("subprocess.run") as m:
|
|
112
|
-
m.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
113
|
-
tools_mod._run_xray(["health"], timeout=60)
|
|
114
|
-
self.assertEqual(m.call_args[1]["timeout"], 60)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
class TestBridgeHelper(unittest.TestCase):
|
|
118
|
-
"""Test the bridge.mjs calling helper."""
|
|
119
|
-
|
|
120
|
-
def test_successful_bridge_call(self):
|
|
121
|
-
with patch("subprocess.run") as m:
|
|
122
|
-
m.return_value = MagicMock(returncode=0, stdout='{"status":"ok"}', stderr="")
|
|
123
|
-
r = tools_mod._bridge_call({"command": "health"})
|
|
124
|
-
self.assertEqual(r["status"], "ok")
|
|
125
|
-
# Should call node with bridge path
|
|
126
|
-
self.assertIn("node", m.call_args[0][0])
|
|
127
|
-
self.assertIn("bridge.mjs", m.call_args[0][0][1])
|
|
128
|
-
|
|
129
|
-
def test_bridge_returns_error(self):
|
|
130
|
-
with patch("subprocess.run") as m:
|
|
131
|
-
m.return_value = MagicMock(returncode=1, stdout="", stderr="module not found")
|
|
132
|
-
r = tools_mod._bridge_call({"command": "health"})
|
|
133
|
-
self.assertIn("error", r)
|
|
134
|
-
|
|
135
|
-
def test_bridge_node_not_found(self):
|
|
136
|
-
with patch("subprocess.run", side_effect=FileNotFoundError):
|
|
137
|
-
r = tools_mod._bridge_call({"command": "health"})
|
|
138
|
-
self.assertEqual(r["error"], "node not found")
|
|
139
|
-
|
|
140
|
-
def test_bridge_timeout(self):
|
|
141
|
-
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("c", 10)):
|
|
142
|
-
r = tools_mod._bridge_call({"command": "health"}, timeout=10)
|
|
143
|
-
self.assertIn("timed out", r["error"])
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
class TestXrayHealth(unittest.TestCase):
|
|
147
|
-
def test_health_via_bridge(self):
|
|
148
|
-
"""v2: health uses bridge first."""
|
|
149
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"status": "ok", "framework": "loaded", "version": "1.15.0", "components": {}}) as m:
|
|
150
|
-
r = json.loads(tools_mod.xray_health({}))
|
|
151
|
-
self.assertEqual(r["status"], "ok")
|
|
152
|
-
self.assertEqual(r["via"], "bridge")
|
|
153
|
-
m.assert_called_once_with({"command": "health"}, timeout=10)
|
|
154
|
-
|
|
155
|
-
def test_health_fallback_to_cli(self):
|
|
156
|
-
"""v2: falls back to CLI when bridge fails."""
|
|
157
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}):
|
|
158
|
-
with patch.object(tools_mod, "_run_xray", return_value='{"status":"ok","output":"healthy"}') as cli:
|
|
159
|
-
r = json.loads(tools_mod.xray_health({}))
|
|
160
|
-
cli.assert_called_once()
|
|
161
|
-
|
|
162
|
-
def test_health_ignores_extra_args(self):
|
|
163
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"status": "ok", "framework": "loaded", "version": "1.0", "components": {}}):
|
|
164
|
-
r = json.loads(tools_mod.xray_health({"x": 1}))
|
|
165
|
-
self.assertEqual(r["status"], "ok")
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
class TestXrayValidate(unittest.TestCase):
|
|
169
|
-
def test_with_files_via_bridge(self):
|
|
170
|
-
"""v2: validate uses bridge first."""
|
|
171
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "fileResults": []}) as m:
|
|
172
|
-
r = json.loads(tools_mod.xray_validate({"files": ["a.ts", "b.ts"], "operation": "commit"}))
|
|
173
|
-
self.assertEqual(r["status"], "passed")
|
|
174
|
-
self.assertEqual(r["files_checked"], 2)
|
|
175
|
-
self.assertEqual(r["via"], "bridge")
|
|
176
|
-
m.assert_called_once()
|
|
177
|
-
|
|
178
|
-
def test_bridge_violations(self):
|
|
179
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": False, "fileResults": [{"file": "a.ts", "passed": False, "violations": ["tests-required"]}]}) as m:
|
|
180
|
-
r = json.loads(tools_mod.xray_validate({"files": ["a.ts"]}))
|
|
181
|
-
self.assertEqual(r["status"], "violations")
|
|
182
|
-
self.assertEqual(r["file_results"][0]["violations"], ["tests-required"])
|
|
183
|
-
|
|
184
|
-
def test_bridge_error_fallback_to_cli(self):
|
|
185
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}):
|
|
186
|
-
with patch.object(tools_mod, "_run_xray", return_value='{"status":"ok","output":"valid"}') as cli:
|
|
187
|
-
r = json.loads(tools_mod.xray_validate({"files": ["a.ts"]}))
|
|
188
|
-
self.assertEqual(r["via"], "cli")
|
|
189
|
-
cli.assert_called_once()
|
|
190
|
-
|
|
191
|
-
def test_no_files_error(self):
|
|
192
|
-
r = json.loads(tools_mod.xray_validate({"files": []}))
|
|
193
|
-
self.assertIn("No files", r["error"])
|
|
194
|
-
|
|
195
|
-
def test_no_files_key_error(self):
|
|
196
|
-
r = json.loads(tools_mod.xray_validate({}))
|
|
197
|
-
self.assertIn("error", r)
|
|
198
|
-
|
|
199
|
-
def test_default_operation(self):
|
|
200
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "fileResults": []}):
|
|
201
|
-
r = json.loads(tools_mod.xray_validate({"files": ["a.ts"]}))
|
|
202
|
-
self.assertEqual(r["operation"], "commit")
|
|
203
|
-
|
|
204
|
-
def test_100_files(self):
|
|
205
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "fileResults": []}) as m:
|
|
206
|
-
fs = [f"f{i}.ts" for i in range(100)]
|
|
207
|
-
r = json.loads(tools_mod.xray_validate({"files": fs}))
|
|
208
|
-
self.assertEqual(r["files_checked"], 100)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
class TestXrayCodexCheck(unittest.TestCase):
|
|
212
|
-
def test_with_code_via_bridge(self):
|
|
213
|
-
"""v2: codex check uses bridge for real quality gate analysis."""
|
|
214
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}) as m:
|
|
215
|
-
r = json.loads(tools_mod.xray_codex_check({"code": "const x = null;", "operation": "create"}))
|
|
216
|
-
self.assertEqual(r["status"], "passed")
|
|
217
|
-
self.assertEqual(r["via"], "bridge")
|
|
218
|
-
m.assert_called_once()
|
|
219
|
-
|
|
220
|
-
def test_with_code_bridge_violations(self):
|
|
221
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": False, "violations": ["console.log found"], "checks": []}):
|
|
222
|
-
r = json.loads(tools_mod.xray_codex_check({"code": "console.log(x)", "operation": "create"}))
|
|
223
|
-
self.assertEqual(r["status"], "violations")
|
|
224
|
-
self.assertEqual(r["violations"], ["console.log found"])
|
|
225
|
-
|
|
226
|
-
def test_with_focus_areas(self):
|
|
227
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}) as m:
|
|
228
|
-
tools_mod.xray_codex_check({"code": "eval()", "operation": "modify", "focus_areas": ["security"]})
|
|
229
|
-
self.assertEqual(m.call_args[0][0]["focusAreas"], ["security"])
|
|
230
|
-
|
|
231
|
-
def test_empty_string_code_treated_as_code(self):
|
|
232
|
-
"""BUG FIX: empty string '' should still be treated as code (is not None)."""
|
|
233
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}) as m:
|
|
234
|
-
r = json.loads(tools_mod.xray_codex_check({"code": "", "operation": "create"}))
|
|
235
|
-
self.assertEqual(r["status"], "passed")
|
|
236
|
-
self.assertEqual(r["code_length"], 0)
|
|
237
|
-
|
|
238
|
-
def test_no_code_bridge_health(self):
|
|
239
|
-
"""No code provided — bridge health check."""
|
|
240
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"framework": "loaded", "version": "1.15.0", "components": {}}) as m:
|
|
241
|
-
r = json.loads(tools_mod.xray_codex_check({"operation": "refactor"}))
|
|
242
|
-
self.assertEqual(r["status"], "ok")
|
|
243
|
-
self.assertIn("Pass", r["note"])
|
|
244
|
-
|
|
245
|
-
def test_no_code_bridge_error_fallback(self):
|
|
246
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}):
|
|
247
|
-
with patch.object(tools_mod, "_run_xray", return_value='{"status":"ok","output":"healthy"}') as cli:
|
|
248
|
-
r = json.loads(tools_mod.xray_codex_check({"operation": "create"}))
|
|
249
|
-
cli.assert_called_once()
|
|
250
|
-
|
|
251
|
-
def test_default_operation(self):
|
|
252
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}):
|
|
253
|
-
r = json.loads(tools_mod.xray_codex_check({"code": "x"}))
|
|
254
|
-
self.assertEqual(r["operation"], "create")
|
|
255
|
-
|
|
256
|
-
def test_multiline_code(self):
|
|
257
|
-
code = "function foo() {\n return null;\n}\n"
|
|
258
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"passed": True, "violations": [], "checks": []}):
|
|
259
|
-
r = json.loads(tools_mod.xray_codex_check({"code": code, "operation": "create"}))
|
|
260
|
-
self.assertEqual(r["code_length"], len(code))
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
class TestPreToolCallHook(unittest.TestCase):
|
|
264
|
-
"""v2: pre_tool_call now runs bridge for code tools and logs to disk."""
|
|
265
|
-
|
|
266
|
-
def setUp(self):
|
|
267
|
-
# Reset session stats
|
|
268
|
-
pi._session_stats = dict.fromkeys(pi._session_stats, 0)
|
|
269
|
-
pi._session_stats["started_at"] = None
|
|
270
|
-
pi._session_stats["session_id"] = None
|
|
271
|
-
|
|
272
|
-
@patch.object(pi, "_call_bridge")
|
|
273
|
-
def test_xray_mcp_no_bridge(self, mock_bridge):
|
|
274
|
-
"""0xRay MCP tools skip bridge entirely."""
|
|
275
|
-
pi._on_pre_tool_call("mcp_xray_lint_lint", {}, "t1")
|
|
276
|
-
self.assertEqual(pi._session_stats["xray_mcp_calls"], 1)
|
|
277
|
-
self.assertEqual(pi._session_stats["native_tool_calls"], 0)
|
|
278
|
-
mock_bridge.assert_not_called()
|
|
279
|
-
|
|
280
|
-
@patch.object(pi, "_call_bridge")
|
|
281
|
-
def test_native_tool_no_bridge(self, mock_bridge):
|
|
282
|
-
"""Non-code native tools don't call bridge."""
|
|
283
|
-
pi._on_pre_tool_call("read_file", {"path": "a.md"}, "t1")
|
|
284
|
-
mock_bridge.assert_not_called()
|
|
285
|
-
|
|
286
|
-
@patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True, "violations": []}, "processors": {"ran": False}})
|
|
287
|
-
def test_code_tool_calls_bridge(self, mock_bridge):
|
|
288
|
-
"""Code-producing tools trigger bridge pre-process."""
|
|
289
|
-
pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
|
|
290
|
-
mock_bridge.assert_called_once()
|
|
291
|
-
call_cmd = mock_bridge.call_args[0][0]
|
|
292
|
-
self.assertEqual(call_cmd["command"], "pre-process")
|
|
293
|
-
self.assertEqual(call_cmd["tool"], "write_file")
|
|
294
|
-
|
|
295
|
-
@patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True, "violations": []}, "processors": {"ran": False}})
|
|
296
|
-
def test_code_tool_increments_stats(self, mock_bridge):
|
|
297
|
-
for t in ["write_file", "patch", "execute_code"]:
|
|
298
|
-
pi._session_stats["code_operations"] = 0
|
|
299
|
-
pi._session_stats["quality_gate_runs"] = 0
|
|
300
|
-
pi._on_pre_tool_call(t, {}, "t1")
|
|
301
|
-
self.assertEqual(pi._session_stats["code_operations"], 1, f"{t}")
|
|
302
|
-
self.assertEqual(pi._session_stats["quality_gate_runs"], 1, f"{t}")
|
|
303
|
-
|
|
304
|
-
@patch.object(pi, "_call_bridge", return_value={"passed": False, "qualityGate": {"passed": False, "violations": ["tests-required: no test"]}, "processors": {"ran": False}})
|
|
305
|
-
def test_quality_gate_block(self, mock_bridge):
|
|
306
|
-
"""Quality gate failures increment block counter."""
|
|
307
|
-
pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
|
|
308
|
-
self.assertEqual(pi._session_stats["quality_gate_blocks"], 1)
|
|
309
|
-
|
|
310
|
-
@patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True}, "processors": {"ran": True, "success": True, "processorCount": 2, "details": [{"name": "preValidate", "success": True}]}})
|
|
311
|
-
def test_pre_processor_stats(self, mock_bridge):
|
|
312
|
-
pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
|
|
313
|
-
self.assertEqual(pi._session_stats["pre_processor_runs"], 1)
|
|
314
|
-
|
|
315
|
-
def test_nudge_terminal(self):
|
|
316
|
-
"""Terminal nudge only fires when command matches a known pattern."""
|
|
317
|
-
# No command arg — no nudge
|
|
318
|
-
with self.assertRaises(AssertionError):
|
|
319
|
-
with self.assertLogs("xray-hermes", level="INFO"):
|
|
320
|
-
pi._on_pre_tool_call("terminal", {}, "t1")
|
|
321
|
-
|
|
322
|
-
# Generic command (git, ls) — no nudge
|
|
323
|
-
with self.assertRaises(AssertionError):
|
|
324
|
-
with self.assertLogs("xray-hermes", level="INFO"):
|
|
325
|
-
pi._on_pre_tool_call("terminal", {"command": "git status"}, "t1")
|
|
326
|
-
|
|
327
|
-
# Grep command — should nudge
|
|
328
|
-
with self.assertLogs("xray-hermes", level="INFO") as cm:
|
|
329
|
-
pi._on_pre_tool_call("terminal", {"command": "grep -r 'pattern' src/"}, "t1")
|
|
330
|
-
self.assertTrue(any("grep" in m for m in cm.output))
|
|
331
|
-
|
|
332
|
-
# npm audit — should nudge
|
|
333
|
-
with self.assertLogs("xray-hermes", level="INFO") as cm:
|
|
334
|
-
pi._on_pre_tool_call("terminal", {"command": "npm audit"}, "t1")
|
|
335
|
-
self.assertTrue(any("audit" in m for m in cm.output))
|
|
336
|
-
|
|
337
|
-
def test_nudge_search_files(self):
|
|
338
|
-
with self.assertLogs("xray-hermes", level="INFO") as cm:
|
|
339
|
-
pi._on_pre_tool_call("search_files", {}, "t1")
|
|
340
|
-
self.assertTrue(any("Tip" in m for m in cm.output))
|
|
341
|
-
|
|
342
|
-
def test_no_nudge_write_file(self):
|
|
343
|
-
"""write_file is a code tool — no nudge, gets bridge instead."""
|
|
344
|
-
with patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True}, "processors": {"ran": False}}):
|
|
345
|
-
with self.assertRaises(AssertionError):
|
|
346
|
-
with self.assertLogs("xray-hermes", level="INFO"):
|
|
347
|
-
pi._on_pre_tool_call("write_file", {}, "t1")
|
|
348
|
-
|
|
349
|
-
def test_accumulates(self):
|
|
350
|
-
with patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True}, "processors": {"ran": False}}):
|
|
351
|
-
for _ in range(5):
|
|
352
|
-
pi._on_pre_tool_call("terminal", {}, "t1")
|
|
353
|
-
self.assertEqual(pi._session_stats["total_tool_calls"], 5)
|
|
354
|
-
|
|
355
|
-
def test_is_xray_mcp(self):
|
|
356
|
-
self.assertTrue(pi._is_xray_mcp("mcp_xray_lint_lint"))
|
|
357
|
-
self.assertTrue(pi._is_xray_mcp("mcp_xray_enforcer_codex_enforcement"))
|
|
358
|
-
self.assertFalse(pi._is_xray_mcp("terminal"))
|
|
359
|
-
self.assertFalse(pi._is_xray_mcp("xray_validate"))
|
|
360
|
-
self.assertFalse(pi._is_xray_mcp("mcp_other_tool"))
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
class TestPostToolCallHook(unittest.TestCase):
|
|
364
|
-
"""v2: post_tool_call logs to disk and calls bridge for code tools."""
|
|
365
|
-
|
|
366
|
-
def setUp(self):
|
|
367
|
-
# Reset post_processor_runs since it accumulates across tests
|
|
368
|
-
pi._session_stats["post_processor_runs"] = 0
|
|
369
|
-
|
|
370
|
-
@patch.object(pi, "_call_bridge")
|
|
371
|
-
def test_non_code_no_bridge(self, mock_bridge):
|
|
372
|
-
"""Non-code tools don't trigger bridge post-process."""
|
|
373
|
-
pi._on_post_tool_call("terminal", {"command": "ls"}, None, "t1")
|
|
374
|
-
mock_bridge.assert_not_called()
|
|
375
|
-
|
|
376
|
-
@patch.object(pi, "_call_bridge", return_value={"processors": {"ran": True, "success": True, "processorCount": 2, "details": []}})
|
|
377
|
-
def test_code_tool_calls_bridge(self, mock_bridge):
|
|
378
|
-
"""Code-producing tools trigger bridge post-process."""
|
|
379
|
-
pi._on_post_tool_call("write_file", {"path": "a.ts"}, None, "t1")
|
|
380
|
-
mock_bridge.assert_called_once()
|
|
381
|
-
call_cmd = mock_bridge.call_args[0][0]
|
|
382
|
-
self.assertEqual(call_cmd["command"], "post-process")
|
|
383
|
-
self.assertEqual(call_cmd["tool"], "write_file")
|
|
384
|
-
|
|
385
|
-
@patch.object(pi, "_call_bridge", return_value={"processors": {"ran": True, "success": True, "processorCount": 1, "details": []}})
|
|
386
|
-
def test_post_processor_stats(self, mock_bridge):
|
|
387
|
-
pi._on_post_tool_call("patch", {"path": "a.ts"}, None, "t1")
|
|
388
|
-
self.assertEqual(pi._session_stats["post_processor_runs"], 1)
|
|
389
|
-
|
|
390
|
-
@patch.object(pi, "_call_bridge", return_value={"processors": {"ran": True, "success": True, "processorCount": 2, "details": []}})
|
|
391
|
-
def test_post_captures_result(self, mock_bridge):
|
|
392
|
-
pi._on_post_tool_call("write_file", {"path": "a.ts"}, {"error": "disk full"}, "t1")
|
|
393
|
-
call_cmd = mock_bridge.call_args[0][0]
|
|
394
|
-
self.assertEqual(call_cmd["error"], "disk full")
|
|
395
|
-
|
|
396
|
-
def test_args_not_dict_no_crash(self):
|
|
397
|
-
pi._on_post_tool_call("write_file", "not a dict", None, "t1")
|
|
398
|
-
|
|
399
|
-
def test_args_none_no_crash(self):
|
|
400
|
-
pi._on_post_tool_call("write_file", None, None, "t1")
|
|
401
|
-
|
|
402
|
-
def test_missing_path_key_no_file_tracking(self):
|
|
403
|
-
"""BUG FIX: missing path key should not crash."""
|
|
404
|
-
pi._on_post_tool_call("write_file", {"content": "hello"}, None, "t1")
|
|
405
|
-
|
|
406
|
-
def test_empty_path_no_file_tracking(self):
|
|
407
|
-
"""Empty string path should not crash."""
|
|
408
|
-
pi._on_post_tool_call("write_file", {"path": ""}, None, "t1")
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
class TestSlashCommand(unittest.TestCase):
|
|
412
|
-
def setUp(self):
|
|
413
|
-
pi._session_stats = {
|
|
414
|
-
"started_at": "2026-03-27T15:00:00Z",
|
|
415
|
-
"session_id": "test-session",
|
|
416
|
-
"code_operations": 5,
|
|
417
|
-
"total_tool_calls": 20,
|
|
418
|
-
"xray_mcp_calls": 12,
|
|
419
|
-
"native_tool_calls": 8,
|
|
420
|
-
"quality_gate_runs": 10,
|
|
421
|
-
"quality_gate_blocks": 2,
|
|
422
|
-
"pre_processor_runs": 8,
|
|
423
|
-
"post_processor_runs": 6,
|
|
424
|
-
"bridge_calls": 15,
|
|
425
|
-
"bridge_errors": 0,
|
|
426
|
-
"subagent_dispatches": 3,
|
|
427
|
-
"subagent_validations": 1,
|
|
428
|
-
"subagent_blocks": 0,
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
def test_stats(self):
|
|
432
|
-
o = pi._xray_command("stats")
|
|
433
|
-
self.assertIn("20", o)
|
|
434
|
-
self.assertIn("12", o)
|
|
435
|
-
self.assertIn("8", o)
|
|
436
|
-
self.assertIn("5", o)
|
|
437
|
-
# v2 new stats
|
|
438
|
-
self.assertIn("10", o) # quality_gate_runs
|
|
439
|
-
self.assertIn("2", o) # quality_gate_blocks
|
|
440
|
-
self.assertIn("15", o) # bridge_calls
|
|
441
|
-
|
|
442
|
-
def test_help(self):
|
|
443
|
-
o = pi._xray_command("help")
|
|
444
|
-
self.assertIn("status", o)
|
|
445
|
-
self.assertIn("help", o)
|
|
446
|
-
|
|
447
|
-
def test_status_via_bridge(self):
|
|
448
|
-
"""v2: status uses bridge, not tools.xray_health."""
|
|
449
|
-
with patch.object(pi, "_call_bridge", return_value={"framework": "loaded", "version": "1.15.0", "components": {"qualityGate": True, "processorManager": True}}) as m:
|
|
450
|
-
o = pi._xray_command("status")
|
|
451
|
-
self.assertIn("loaded", o)
|
|
452
|
-
self.assertIn("1.15.0", o)
|
|
453
|
-
m.assert_called_once_with({"command": "health"}, timeout=10)
|
|
454
|
-
|
|
455
|
-
def test_status_bridge_error(self):
|
|
456
|
-
with patch.object(pi, "_call_bridge", return_value={"error": "node not found"}):
|
|
457
|
-
o = pi._xray_command("status")
|
|
458
|
-
self.assertIn("node not found", o)
|
|
459
|
-
|
|
460
|
-
def test_default_status(self):
|
|
461
|
-
with patch.object(pi, "_call_bridge", return_value={"framework": "loaded", "version": "1.0", "components": {}}):
|
|
462
|
-
o = pi._xray_command("")
|
|
463
|
-
self.assertIn("loaded", o)
|
|
464
|
-
|
|
465
|
-
def test_stats_null_started_at(self):
|
|
466
|
-
pi._session_stats["started_at"] = None
|
|
467
|
-
pi._session_stats["session_id"] = None
|
|
468
|
-
o = pi._xray_command("stats")
|
|
469
|
-
self.assertIn("N/A", o)
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
class TestSessionStartHook(unittest.TestCase):
|
|
473
|
-
def test_resets_stats(self):
|
|
474
|
-
pi._session_stats["total_tool_calls"] = 99
|
|
475
|
-
pi._session_stats["code_operations"] = 50
|
|
476
|
-
pi._session_stats["bridge_calls"] = 20
|
|
477
|
-
pi._session_stats["quality_gate_blocks"] = 5
|
|
478
|
-
pi._on_session_start("s1", "cli")
|
|
479
|
-
self.assertEqual(pi._session_stats["total_tool_calls"], 0)
|
|
480
|
-
self.assertEqual(pi._session_stats["code_operations"], 0)
|
|
481
|
-
self.assertEqual(pi._session_stats["bridge_calls"], 0)
|
|
482
|
-
self.assertEqual(pi._session_stats["quality_gate_blocks"], 0)
|
|
483
|
-
self.assertIsNotNone(pi._session_stats["started_at"])
|
|
484
|
-
self.assertEqual(pi._session_stats["session_id"], "s1")
|
|
485
|
-
|
|
486
|
-
def test_logs(self):
|
|
487
|
-
with self.assertLogs("xray-hermes", level="INFO") as cm:
|
|
488
|
-
pi._on_session_start("s1", "telegram")
|
|
489
|
-
self.assertTrue(any("s1" in m for m in cm.output))
|
|
490
|
-
self.assertTrue(any("telegram" in m for m in cm.output))
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
class TestRegisterIntegration(unittest.TestCase):
|
|
494
|
-
def test_wires_three_tools(self):
|
|
495
|
-
ctx = MagicMock()
|
|
496
|
-
pi.register(ctx)
|
|
497
|
-
names = [c[1]["name"] for c in ctx.register_tool.call_args_list]
|
|
498
|
-
self.assertEqual(set(names), {"xray_validate", "xray_codex_check", "xray_health", "xray_hooks"})
|
|
499
|
-
|
|
500
|
-
def test_toolset_name(self):
|
|
501
|
-
ctx = MagicMock()
|
|
502
|
-
pi.register(ctx)
|
|
503
|
-
for c in ctx.register_tool.call_args_list:
|
|
504
|
-
self.assertEqual(c[1]["toolset"], "0xray-hermes")
|
|
505
|
-
|
|
506
|
-
def test_schemas_wired(self):
|
|
507
|
-
ctx = MagicMock()
|
|
508
|
-
pi.register(ctx)
|
|
509
|
-
sm = {c[1]["name"]: c[1]["schema"] for c in ctx.register_tool.call_args_list}
|
|
510
|
-
self.assertIs(sm["xray_validate"], schemas.XRAY_VALIDATE)
|
|
511
|
-
self.assertIs(sm["xray_codex_check"], schemas.XRAY_CODEX_CHECK)
|
|
512
|
-
self.assertIs(sm["xray_health"], schemas.XRAY_HEALTH)
|
|
513
|
-
|
|
514
|
-
def test_handlers_wired(self):
|
|
515
|
-
ctx = MagicMock()
|
|
516
|
-
pi.register(ctx)
|
|
517
|
-
hm = {c[1]["name"]: c[1]["handler"] for c in ctx.register_tool.call_args_list}
|
|
518
|
-
self.assertIs(hm["xray_validate"], tools_mod.xray_validate)
|
|
519
|
-
self.assertIs(hm["xray_codex_check"], tools_mod.xray_codex_check)
|
|
520
|
-
self.assertIs(hm["xray_health"], tools_mod.xray_health)
|
|
521
|
-
|
|
522
|
-
def test_hooks_registered(self):
|
|
523
|
-
ctx = MagicMock()
|
|
524
|
-
pi.register(ctx)
|
|
525
|
-
names = [c[0][0] for c in ctx.register_hook.call_args_list]
|
|
526
|
-
self.assertIn("pre_tool_call", names)
|
|
527
|
-
self.assertIn("post_tool_call", names)
|
|
528
|
-
|
|
529
|
-
def test_hooks_are_callable(self):
|
|
530
|
-
ctx = MagicMock()
|
|
531
|
-
pi.register(ctx)
|
|
532
|
-
for c in ctx.register_hook.call_args_list:
|
|
533
|
-
self.assertTrue(callable(c[0][1]))
|
|
534
|
-
|
|
535
|
-
def test_slash_command_attempted(self):
|
|
536
|
-
ctx = MagicMock()
|
|
537
|
-
pi.register(ctx)
|
|
538
|
-
cmds = [c for c in ctx.method_calls if "register_command" in str(c)]
|
|
539
|
-
self.assertTrue(len(cmds) >= 1)
|
|
540
|
-
|
|
541
|
-
def test_survives_missing_session_hook(self):
|
|
542
|
-
ctx = MagicMock()
|
|
543
|
-
# pre_tool_call, post_tool_call succeed; on_session_start fails; 3 lifecycle hooks fail
|
|
544
|
-
ctx.register_hook.side_effect = [None, None, AttributeError("nope"), AttributeError("nope"), AttributeError("nope"), AttributeError("nope")]
|
|
545
|
-
pi.register(ctx) # should not raise
|
|
546
|
-
|
|
547
|
-
def test_survives_missing_command_reg(self):
|
|
548
|
-
ctx = MagicMock()
|
|
549
|
-
ctx.register_command.side_effect = TypeError("nope")
|
|
550
|
-
pi.register(ctx)
|
|
551
|
-
|
|
552
|
-
def test_logs_on_load(self):
|
|
553
|
-
ctx = MagicMock()
|
|
554
|
-
with self.assertLogs("xray-hermes", level="INFO") as cm:
|
|
555
|
-
pi.register(ctx)
|
|
556
|
-
self.assertTrue(any("Plugin" in m and "loaded" in m for m in cm.output))
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
class TestFileLogging(unittest.TestCase):
|
|
560
|
-
"""Test that tool events are written to log files."""
|
|
561
|
-
|
|
562
|
-
def test_log_tool_event_creates_file(self):
|
|
563
|
-
with tempfile.TemporaryDirectory() as td:
|
|
564
|
-
log_dir = Path(td)
|
|
565
|
-
# Temporarily override LOG_DIR
|
|
566
|
-
original = pi.LOG_DIR
|
|
567
|
-
pi.LOG_DIR = log_dir
|
|
568
|
-
|
|
569
|
-
pi._log_tool_event("start", "terminal", {"command": "ls"})
|
|
570
|
-
pi._log_tool_event("complete", "terminal", {"command": "ls"}, duration=100)
|
|
571
|
-
|
|
572
|
-
activity_file = log_dir / "plugin-tool-events.log"
|
|
573
|
-
self.assertTrue(activity_file.exists())
|
|
574
|
-
|
|
575
|
-
content = activity_file.read_text()
|
|
576
|
-
self.assertIn("tool-started", content)
|
|
577
|
-
self.assertIn("tool-complete", content)
|
|
578
|
-
self.assertIn("SUCCESS", content)
|
|
579
|
-
|
|
580
|
-
pi.LOG_DIR = original
|
|
581
|
-
|
|
582
|
-
def test_log_to_file_creates_file(self):
|
|
583
|
-
with tempfile.TemporaryDirectory() as td:
|
|
584
|
-
log_dir = Path(td)
|
|
585
|
-
original = pi.LOG_DIR
|
|
586
|
-
pi.LOG_DIR = log_dir
|
|
587
|
-
|
|
588
|
-
pi._log_to_file("activity.log", "[test] hello world")
|
|
589
|
-
|
|
590
|
-
activity_file = log_dir / "activity.log"
|
|
591
|
-
self.assertTrue(activity_file.exists())
|
|
592
|
-
content = activity_file.read_text()
|
|
593
|
-
self.assertIn("[test] hello world", content)
|
|
594
|
-
|
|
595
|
-
pi.LOG_DIR = original
|
|
596
|
-
|
|
597
|
-
def test_log_to_file_survives_permission_error(self):
|
|
598
|
-
"""Should never crash the agent over logging."""
|
|
599
|
-
original = pi.LOG_DIR
|
|
600
|
-
pi.LOG_DIR = Path("/nonexistent/path/that/does/not/exist/and/cannot/be/created")
|
|
601
|
-
pi._log_to_file("activity.log", "should not crash") # noqa: B023
|
|
602
|
-
pi.LOG_DIR = original
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
class TestLiveBridge(unittest.TestCase):
|
|
606
|
-
"""Live test: actually call bridge.mjs if it exists."""
|
|
607
|
-
|
|
608
|
-
def test_bridge_health(self):
|
|
609
|
-
bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
|
|
610
|
-
if not bridge_path.exists():
|
|
611
|
-
self.skipTest("bridge.mjs not built yet")
|
|
612
|
-
|
|
613
|
-
r = tools_mod._bridge_call({"command": "health"}, timeout=10)
|
|
614
|
-
self.assertNotIn("error", r)
|
|
615
|
-
self.assertIn("status", r)
|
|
616
|
-
|
|
617
|
-
def test_bridge_stats(self):
|
|
618
|
-
bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
|
|
619
|
-
if not bridge_path.exists():
|
|
620
|
-
self.skipTest("bridge.mjs not built yet")
|
|
621
|
-
|
|
622
|
-
r = tools_mod._bridge_call({"command": "stats"}, timeout=5)
|
|
623
|
-
self.assertIn("frameworkReady", r)
|
|
624
|
-
|
|
625
|
-
def test_bridge_quality_gate(self):
|
|
626
|
-
bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
|
|
627
|
-
if not bridge_path.exists():
|
|
628
|
-
self.skipTest("bridge.mjs not built yet")
|
|
629
|
-
|
|
630
|
-
# Clean code should pass
|
|
631
|
-
r = tools_mod._bridge_call({
|
|
632
|
-
"command": "codex-check",
|
|
633
|
-
"code": "const x: number = 42;",
|
|
634
|
-
}, timeout=10)
|
|
635
|
-
self.assertNotIn("error", r)
|
|
636
|
-
self.assertIn("passed", r)
|
|
637
|
-
|
|
638
|
-
def test_bridge_quality_gate_violation(self):
|
|
639
|
-
bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
|
|
640
|
-
if not bridge_path.exists():
|
|
641
|
-
self.skipTest("bridge.mjs not built yet")
|
|
642
|
-
|
|
643
|
-
# Code with console.log should fail
|
|
644
|
-
r = tools_mod._bridge_call({
|
|
645
|
-
"command": "codex-check",
|
|
646
|
-
"code": "console.log('hello');",
|
|
647
|
-
}, timeout=10)
|
|
648
|
-
self.assertNotIn("error", r)
|
|
649
|
-
# console.log is a violation
|
|
650
|
-
if r.get("passed") is False:
|
|
651
|
-
self.assertTrue(
|
|
652
|
-
any("console.log" in v.get("message", "") for v in r.get("violations", [])),
|
|
653
|
-
f"Expected 'console.log' in violations, got: {r.get('violations', [])}",
|
|
654
|
-
)
|
|
655
|
-
|
|
656
|
-
def test_bridge_positional_health(self):
|
|
657
|
-
"""Positional arg mode: node bridge.mjs health --cwd /path (no stdin pipe)."""
|
|
658
|
-
bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
|
|
659
|
-
if not bridge_path.exists():
|
|
660
|
-
self.skipTest("bridge.mjs not built yet")
|
|
661
|
-
|
|
662
|
-
# Use stringray dev repo as project root (has package.json)
|
|
663
|
-
xray_root = str(Path(PLUGIN_DIR).parent.parent.parent / "dev" / "stringray")
|
|
664
|
-
cwd = xray_root if Path(xray_root).exists() else PLUGIN_DIR
|
|
665
|
-
|
|
666
|
-
r = subprocess.run(
|
|
667
|
-
["node", str(bridge_path), "health", "--cwd", cwd],
|
|
668
|
-
capture_output=True, text=True, timeout=10,
|
|
669
|
-
)
|
|
670
|
-
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
|
|
671
|
-
data = json.loads(r.stdout)
|
|
672
|
-
self.assertEqual(data["status"], "ok")
|
|
673
|
-
self.assertIn("framework", data)
|
|
674
|
-
|
|
675
|
-
def test_bridge_positional_stats(self):
|
|
676
|
-
"""Positional stats: no stdin needed."""
|
|
677
|
-
bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
|
|
678
|
-
if not bridge_path.exists():
|
|
679
|
-
self.skipTest("bridge.mjs not built yet")
|
|
680
|
-
|
|
681
|
-
xray_root = str(Path(PLUGIN_DIR).parent.parent.parent / "dev" / "stringray")
|
|
682
|
-
cwd = xray_root if Path(xray_root).exists() else PLUGIN_DIR
|
|
683
|
-
|
|
684
|
-
r = subprocess.run(
|
|
685
|
-
["node", str(bridge_path), "stats", "--cwd", cwd],
|
|
686
|
-
capture_output=True, text=True, timeout=10,
|
|
687
|
-
)
|
|
688
|
-
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
|
|
689
|
-
data = json.loads(r.stdout)
|
|
690
|
-
self.assertIn("frameworkReady", data)
|
|
691
|
-
|
|
692
|
-
def test_bridge_positional_with_json_payload(self):
|
|
693
|
-
"""Positional mode with --json payload for commands needing args."""
|
|
694
|
-
bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
|
|
695
|
-
if not bridge_path.exists():
|
|
696
|
-
self.skipTest("bridge.mjs not built yet")
|
|
697
|
-
|
|
698
|
-
xray_root = str(Path(PLUGIN_DIR).parent.parent.parent / "dev" / "stringray")
|
|
699
|
-
cwd = xray_root if Path(xray_root).exists() else PLUGIN_DIR
|
|
700
|
-
|
|
701
|
-
r = subprocess.run(
|
|
702
|
-
["node", str(bridge_path), "validate", "--cwd", cwd,
|
|
703
|
-
"--json", json.dumps({"files": ["src/index.ts"], "operation": "commit"})],
|
|
704
|
-
capture_output=True, text=True, timeout=15,
|
|
705
|
-
)
|
|
706
|
-
self.assertEqual(r.returncode, 0, f"stderr: {r.stderr}")
|
|
707
|
-
data = json.loads(r.stdout)
|
|
708
|
-
self.assertIn("passed", data)
|
|
709
|
-
|
|
710
|
-
def test_bridge_positional_invalid_json(self):
|
|
711
|
-
"""Positional mode with invalid --json returns error."""
|
|
712
|
-
bridge_path = Path(PLUGIN_DIR) / "bridge.mjs"
|
|
713
|
-
if not bridge_path.exists():
|
|
714
|
-
self.skipTest("bridge.mjs not built yet")
|
|
715
|
-
|
|
716
|
-
xray_root = str(Path(PLUGIN_DIR).parent.parent.parent / "dev" / "stringray")
|
|
717
|
-
cwd = xray_root if Path(xray_root).exists() else PLUGIN_DIR
|
|
718
|
-
|
|
719
|
-
r = subprocess.run(
|
|
720
|
-
["node", str(bridge_path), "validate", "--cwd", cwd,
|
|
721
|
-
"--json", "not-json"],
|
|
722
|
-
capture_output=True, text=True, timeout=10,
|
|
723
|
-
)
|
|
724
|
-
# Should fail with error about invalid payload
|
|
725
|
-
data = json.loads(r.stdout)
|
|
726
|
-
self.assertIn("error", data)
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
class TestBridgeErrorPaths(unittest.TestCase):
|
|
730
|
-
"""Cover remaining bridge error branches in tools.py."""
|
|
731
|
-
|
|
732
|
-
def test_bridge_json_decode_error(self):
|
|
733
|
-
"""_bridge_call with non-JSON stdout returns error."""
|
|
734
|
-
with patch("subprocess.run") as m:
|
|
735
|
-
m.return_value = MagicMock(returncode=0, stdout="not json", stderr="")
|
|
736
|
-
r = tools_mod._bridge_call({"command": "health"})
|
|
737
|
-
self.assertIn("error", r)
|
|
738
|
-
|
|
739
|
-
def test_bridge_os_error(self):
|
|
740
|
-
"""_bridge_call with OSError during subprocess returns error."""
|
|
741
|
-
with patch("subprocess.run", side_effect=OSError("broken pipe")):
|
|
742
|
-
r = tools_mod._bridge_call({"command": "health"})
|
|
743
|
-
self.assertIn("error", r)
|
|
744
|
-
|
|
745
|
-
def test_bridge_generic_exception_in_run_xray(self):
|
|
746
|
-
"""_run_xray catches non-standard exceptions."""
|
|
747
|
-
with patch("subprocess.run", side_effect=RuntimeError("unexpected")):
|
|
748
|
-
r = json.loads(tools_mod._run_xray(["health"]))
|
|
749
|
-
self.assertIn("unexpected", r["error"])
|
|
750
|
-
|
|
751
|
-
def test_validate_cli_fallback_error(self):
|
|
752
|
-
"""xray_validate CLI fallback when CLI returns an error JSON."""
|
|
753
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"error": "bridge down"}):
|
|
754
|
-
with patch.object(tools_mod, "_run_xray", return_value='{"error": "validation failed"}'):
|
|
755
|
-
r = json.loads(tools_mod.xray_validate({"files": ["a.ts"]}))
|
|
756
|
-
# CLI error path returns raw result without "via" key
|
|
757
|
-
self.assertIn("error", r)
|
|
758
|
-
|
|
759
|
-
def test_codex_check_static_fallback(self):
|
|
760
|
-
"""xray_codex_check with code but bridge down falls back to static analysis."""
|
|
761
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"error": "bridge down"}):
|
|
762
|
-
r = json.loads(tools_mod.xray_codex_check({"code": "console.log(1)", "operation": "create"}))
|
|
763
|
-
self.assertEqual(r["via"], "static")
|
|
764
|
-
self.assertIn("basic analysis", r["note"])
|
|
765
|
-
|
|
766
|
-
def test_codex_check_cli_health_error(self):
|
|
767
|
-
"""xray_codex_check no-code path: bridge error + CLI also errors."""
|
|
768
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"error": "no node"}):
|
|
769
|
-
with patch.object(tools_mod, "_run_xray", return_value='{"error": "0xray not found"}'):
|
|
770
|
-
r = json.loads(tools_mod.xray_codex_check({"operation": "create"}))
|
|
771
|
-
self.assertIn("error", r)
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
class TestGetProjectRoot(unittest.TestCase):
|
|
775
|
-
"""Test the _get_project_root helper in tools.py."""
|
|
776
|
-
|
|
777
|
-
def test_returns_cwd_when_no_package_json(self):
|
|
778
|
-
"""With no package.json in any ancestor, falls back to cwd."""
|
|
779
|
-
# _get_project_root delegates to __init__.py's PROJECT_ROOT.
|
|
780
|
-
# We verify the function exists and is callable.
|
|
781
|
-
self.assertTrue(callable(tools_mod._get_project_root))
|
|
782
|
-
# Verify it returns a Path-like value
|
|
783
|
-
result = tools_mod._get_project_root()
|
|
784
|
-
self.assertIsNotNone(result)
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
class TestPreToolCallBridgeErrors(unittest.TestCase):
|
|
788
|
-
"""Test pre_tool_call hook when bridge returns errors."""
|
|
789
|
-
|
|
790
|
-
def setUp(self):
|
|
791
|
-
pi._session_stats = dict.fromkeys(pi._session_stats, 0)
|
|
792
|
-
pi._session_stats["started_at"] = None
|
|
793
|
-
pi._session_stats["session_id"] = None
|
|
794
|
-
|
|
795
|
-
@patch.object(pi, "_call_bridge", return_value={"error": "bridge crashed"})
|
|
796
|
-
def test_code_tool_bridge_error_does_not_crash(self, mock_bridge):
|
|
797
|
-
"""Bridge error during pre-process should not crash the hook."""
|
|
798
|
-
pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
|
|
799
|
-
self.assertEqual(pi._session_stats["code_operations"], 1)
|
|
800
|
-
# Note: bridge_calls stat is inside the real _bridge_call,
|
|
801
|
-
# so mocking it doesn't increment the counter. Verify hook doesn't crash.
|
|
802
|
-
mock_bridge.assert_called_once()
|
|
803
|
-
|
|
804
|
-
@patch.object(pi, "_call_bridge", return_value={"error": "timeout"})
|
|
805
|
-
def test_multiple_code_tools_with_bridge_errors(self, mock_bridge):
|
|
806
|
-
"""Multiple bridge errors accumulate properly."""
|
|
807
|
-
for i in range(3):
|
|
808
|
-
pi._on_pre_tool_call("write_file", {"path": f"f{i}.ts"}, "t1")
|
|
809
|
-
self.assertEqual(pi._session_stats["code_operations"], 3)
|
|
810
|
-
self.assertEqual(pi._session_stats["total_tool_calls"], 3)
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
class TestPostToolCallBridgeErrors(unittest.TestCase):
|
|
814
|
-
"""Test post_tool_call hook when bridge returns errors."""
|
|
815
|
-
|
|
816
|
-
def setUp(self):
|
|
817
|
-
pi._session_stats["post_processor_runs"] = 0
|
|
818
|
-
|
|
819
|
-
@patch.object(pi, "_call_bridge", return_value={"error": "bridge down"})
|
|
820
|
-
def test_code_tool_post_bridge_error(self, mock_bridge):
|
|
821
|
-
"""Bridge error during post-process should not crash."""
|
|
822
|
-
pi._on_post_tool_call("write_file", {"path": "a.ts"}, None, "t1")
|
|
823
|
-
mock_bridge.assert_called_once()
|
|
824
|
-
|
|
825
|
-
@patch.object(pi, "_call_bridge", return_value={"processors": {"ran": False}})
|
|
826
|
-
def test_code_tool_processors_not_ran(self, mock_bridge):
|
|
827
|
-
"""Processors not running is handled gracefully."""
|
|
828
|
-
pi._on_post_tool_call("execute_code", {"command": "echo hi"}, {"duration": 42}, "t1")
|
|
829
|
-
self.assertEqual(pi._session_stats["post_processor_runs"], 0)
|
|
830
|
-
|
|
831
|
-
@patch.object(pi, "_call_bridge", return_value={"processors": {"ran": True, "success": False, "processorCount": 1, "details": [{"name": "testAutoCreation", "success": False, "error": "no test file"}]}})
|
|
832
|
-
def test_post_processor_failure_logging(self, mock_bridge):
|
|
833
|
-
"""Failed post-processors are tracked but don't crash."""
|
|
834
|
-
pi._on_post_tool_call("write_file", {"path": "a.ts"}, None, "t1")
|
|
835
|
-
self.assertEqual(pi._session_stats["post_processor_runs"], 1)
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
class TestPreToolCallEdgeCases(unittest.TestCase):
|
|
839
|
-
"""Edge cases for pre_tool_call."""
|
|
840
|
-
|
|
841
|
-
def setUp(self):
|
|
842
|
-
pi._session_stats = dict.fromkeys(pi._session_stats, 0)
|
|
843
|
-
pi._session_stats["started_at"] = None
|
|
844
|
-
pi._session_stats["session_id"] = None
|
|
845
|
-
|
|
846
|
-
@patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True}, "processors": {"ran": False}})
|
|
847
|
-
def test_all_code_tools_trigger_bridge(self, mock_bridge):
|
|
848
|
-
"""Every tool in _CODE_TOOLS calls the bridge."""
|
|
849
|
-
code_tools = ["write_file", "patch", "execute_code", "write", "edit"]
|
|
850
|
-
for tool in code_tools:
|
|
851
|
-
pi._session_stats["code_operations"] = 0
|
|
852
|
-
pi._on_pre_tool_call(tool, {}, "t1")
|
|
853
|
-
self.assertEqual(pi._session_stats["code_operations"], 1, f"{tool} should be a code tool")
|
|
854
|
-
|
|
855
|
-
@patch.object(pi, "_call_bridge")
|
|
856
|
-
def test_unknown_tool_not_xray_mcp(self, mock_bridge):
|
|
857
|
-
"""Unknown tools should be treated as native tools."""
|
|
858
|
-
pi._on_pre_tool_call("some_random_tool", {}, "t1")
|
|
859
|
-
self.assertEqual(pi._session_stats["native_tool_calls"], 1)
|
|
860
|
-
mock_bridge.assert_not_called()
|
|
861
|
-
|
|
862
|
-
def test_xray_validate_tool_not_treated_as_mcp(self):
|
|
863
|
-
"""xray_validate is a native tool, not an MCP tool."""
|
|
864
|
-
pi._on_pre_tool_call("xray_validate", {}, "t1")
|
|
865
|
-
self.assertEqual(pi._session_stats["native_tool_calls"], 1)
|
|
866
|
-
self.assertEqual(pi._session_stats["xray_mcp_calls"], 0)
|
|
867
|
-
|
|
868
|
-
@patch.object(pi, "_call_bridge", return_value={"passed": True, "qualityGate": {"passed": True, "violations": []}, "processors": {"ran": True, "success": True, "processorCount": 3, "details": [{"name": "p1", "success": True}, {"name": "p2", "success": True}, {"name": "p3", "success": False, "error": "failed"}]}})
|
|
869
|
-
def test_pre_processor_partial_failure(self, mock_bridge):
|
|
870
|
-
"""Pre-processors with partial failure still count as ran."""
|
|
871
|
-
pi._on_pre_tool_call("write_file", {"path": "a.ts"}, "t1")
|
|
872
|
-
self.assertEqual(pi._session_stats["pre_processor_runs"], 1)
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
class TestSlashCommandEdgeCases(unittest.TestCase):
|
|
876
|
-
"""Edge cases for the slash command handler."""
|
|
877
|
-
|
|
878
|
-
def test_unknown_command_defaults_to_status(self):
|
|
879
|
-
"""Unknown args default to status."""
|
|
880
|
-
with patch.object(pi, "_call_bridge", return_value={"framework": "loaded", "version": "1.0", "components": {}}) as m:
|
|
881
|
-
pi._xray_command("something-random")
|
|
882
|
-
m.assert_called_once_with({"command": "health"}, timeout=10)
|
|
883
|
-
|
|
884
|
-
def test_case_insensitive(self):
|
|
885
|
-
"""Command arg is lowercased."""
|
|
886
|
-
o = pi._xray_command(" STATS ")
|
|
887
|
-
self.assertIn("Session", o)
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
class TestLogToFileTimestamps(unittest.TestCase):
|
|
891
|
-
"""Verify log file formatting."""
|
|
892
|
-
|
|
893
|
-
def test_log_entry_has_iso_timestamp(self):
|
|
894
|
-
"""Every log entry should start with an ISO timestamp."""
|
|
895
|
-
import re
|
|
896
|
-
with tempfile.TemporaryDirectory() as td:
|
|
897
|
-
log_dir = Path(td)
|
|
898
|
-
original = pi.LOG_DIR
|
|
899
|
-
pi.LOG_DIR = log_dir
|
|
900
|
-
|
|
901
|
-
pi._log_to_file("test.log", "[test] message")
|
|
902
|
-
content = (log_dir / "test.log").read_text()
|
|
903
|
-
# ISO timestamp: 2026-03-27T17:00:00Z or similar
|
|
904
|
-
self.assertTrue(re.match(r"\d{4}-\d{2}-\d{2}T", content.split(" ")[0]))
|
|
905
|
-
|
|
906
|
-
pi.LOG_DIR = original
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
class TestPostToolCallDuration(unittest.TestCase):
|
|
910
|
-
"""Test that duration is correctly extracted from results."""
|
|
911
|
-
|
|
912
|
-
def setUp(self):
|
|
913
|
-
pi._session_stats["post_processor_runs"] = 0
|
|
914
|
-
|
|
915
|
-
@patch.object(pi, "_call_bridge", return_value={"processors": {"ran": False}})
|
|
916
|
-
def test_duration_extracted_from_result_dict(self, mock_bridge):
|
|
917
|
-
"""Duration from result dict is logged correctly."""
|
|
918
|
-
# We verify the post hook doesn't crash with duration in result
|
|
919
|
-
pi._on_post_tool_call("write_file", {"path": "a.ts"}, {"duration": 1234, "success": True}, "t1")
|
|
920
|
-
# No crash = pass
|
|
921
|
-
|
|
922
|
-
@patch.object(pi, "_call_bridge", return_value={"processors": {"ran": False}})
|
|
923
|
-
def test_non_dict_result_no_crash(self, mock_bridge):
|
|
924
|
-
"""Non-dict result doesn't crash the hook."""
|
|
925
|
-
pi._on_post_tool_call("write_file", {"path": "a.ts"}, "string result", "t1")
|
|
926
|
-
|
|
927
|
-
@patch.object(pi, "_call_bridge", return_value={"processors": {"ran": False}})
|
|
928
|
-
def test_none_result_no_crash(self, mock_bridge):
|
|
929
|
-
"""None result doesn't crash the hook."""
|
|
930
|
-
pi._on_post_tool_call("write_file", {"path": "a.ts"}, None, "t1")
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
class TestBridgeHelperTimeoutDefault(unittest.TestCase):
|
|
934
|
-
"""Verify bridge timeout defaults."""
|
|
935
|
-
|
|
936
|
-
def test_default_timeout(self):
|
|
937
|
-
"""_bridge_call defaults to 30s timeout."""
|
|
938
|
-
with patch("subprocess.run") as m:
|
|
939
|
-
m.return_value = MagicMock(returncode=0, stdout='{"ok":true}', stderr="")
|
|
940
|
-
tools_mod._bridge_call({"command": "health"})
|
|
941
|
-
self.assertEqual(m.call_args[1]["timeout"], 30)
|
|
942
|
-
|
|
943
|
-
def test_custom_timeout(self):
|
|
944
|
-
"""_bridge_call respects custom timeout."""
|
|
945
|
-
with patch("subprocess.run") as m:
|
|
946
|
-
m.return_value = MagicMock(returncode=0, stdout='{"ok":true}', stderr="")
|
|
947
|
-
tools_mod._bridge_call({"command": "health"}, timeout=5)
|
|
948
|
-
self.assertEqual(m.call_args[1]["timeout"], 5)
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
class TestXrayHooksTool(unittest.TestCase):
|
|
952
|
-
"""Tests for the xray_hooks tool."""
|
|
953
|
-
|
|
954
|
-
def test_list_via_bridge(self):
|
|
955
|
-
"""list action uses bridge when available."""
|
|
956
|
-
with patch.object(tools_mod, "_bridge_call", return_value={
|
|
957
|
-
"status": "ok", "action": "list",
|
|
958
|
-
"hooks": {"managed": ["pre-commit"], "missing": [], "external": [], "stale": []},
|
|
959
|
-
}) as m:
|
|
960
|
-
r = json.loads(tools_mod.xray_hooks({"action": "list"}))
|
|
961
|
-
self.assertEqual(r["status"], "ok")
|
|
962
|
-
self.assertEqual(r["via"], "bridge")
|
|
963
|
-
m.assert_called_once()
|
|
964
|
-
call_cmd = m.call_args[0][0]
|
|
965
|
-
self.assertEqual(call_cmd["command"], "hooks")
|
|
966
|
-
self.assertEqual(call_cmd["action"], "list")
|
|
967
|
-
|
|
968
|
-
def test_install_via_bridge(self):
|
|
969
|
-
"""install action uses bridge."""
|
|
970
|
-
with patch.object(tools_mod, "_bridge_call", return_value={
|
|
971
|
-
"status": "ok", "action": "install", "installed": ["pre-commit", "post-commit"],
|
|
972
|
-
"skipped": [], "errors": [],
|
|
973
|
-
}) as m:
|
|
974
|
-
r = json.loads(tools_mod.xray_hooks({"action": "install"}))
|
|
975
|
-
self.assertEqual(r["via"], "bridge")
|
|
976
|
-
self.assertEqual(len(r["result"]["installed"]), 2)
|
|
977
|
-
|
|
978
|
-
def test_uninstall_via_bridge(self):
|
|
979
|
-
"""uninstall action uses bridge."""
|
|
980
|
-
with patch.object(tools_mod, "_bridge_call", return_value={
|
|
981
|
-
"status": "ok", "action": "uninstall", "removed": ["pre-commit"], "restored": [],
|
|
982
|
-
}) as m:
|
|
983
|
-
r = json.loads(tools_mod.xray_hooks({"action": "uninstall"}))
|
|
984
|
-
self.assertEqual(r["via"], "bridge")
|
|
985
|
-
|
|
986
|
-
def test_bridge_error_fallback(self):
|
|
987
|
-
"""Falls back to file-based when bridge errors."""
|
|
988
|
-
with patch.object(tools_mod, "_bridge_call", return_value={"error": "node not found"}):
|
|
989
|
-
# Without a real git repo, should return error
|
|
990
|
-
r = json.loads(tools_mod.xray_hooks({"action": "list"}))
|
|
991
|
-
self.assertIn("via", r)
|
|
992
|
-
|
|
993
|
-
def test_specific_hooks(self):
|
|
994
|
-
"""Can request specific hooks."""
|
|
995
|
-
with patch.object(tools_mod, "_bridge_call", return_value={
|
|
996
|
-
"status": "ok", "action": "list",
|
|
997
|
-
"hooks": {"managed": [], "missing": ["pre-commit"], "external": [], "stale": []},
|
|
998
|
-
}) as m:
|
|
999
|
-
tools_mod.xray_hooks({"action": "list", "hooks": ["pre-commit"]})
|
|
1000
|
-
call_cmd = m.call_args[0][0]
|
|
1001
|
-
self.assertEqual(call_cmd["hooks"], ["pre-commit"])
|
|
1002
|
-
|
|
1003
|
-
def test_status_defaults_to_list(self):
|
|
1004
|
-
"""status action works like list."""
|
|
1005
|
-
with patch.object(tools_mod, "_bridge_call", return_value={
|
|
1006
|
-
"status": "ok", "action": "status",
|
|
1007
|
-
"hooks": {"managed": [], "missing": [], "external": [], "stale": []},
|
|
1008
|
-
}) as m:
|
|
1009
|
-
r = json.loads(tools_mod.xray_hooks({"action": "status"}))
|
|
1010
|
-
self.assertEqual(r["status"], "ok")
|
|
1011
|
-
m.assert_called_once()
|
|
1012
|
-
|
|
1013
|
-
def test_default_action_is_list(self):
|
|
1014
|
-
"""Missing action defaults to list."""
|
|
1015
|
-
with patch.object(tools_mod, "_bridge_call", return_value={
|
|
1016
|
-
"status": "ok", "action": "list",
|
|
1017
|
-
"hooks": {"managed": [], "missing": [], "external": [], "stale": []},
|
|
1018
|
-
}) as m:
|
|
1019
|
-
tools_mod.xray_hooks({})
|
|
1020
|
-
call_cmd = m.call_args[0][0]
|
|
1021
|
-
self.assertEqual(call_cmd["action"], "list")
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
class TestXrayHooksSchema(unittest.TestCase):
|
|
1025
|
-
"""Tests for the XRAY_HOOKS schema."""
|
|
1026
|
-
|
|
1027
|
-
def test_schema_has_required_fields(self):
|
|
1028
|
-
s = schemas.XRAY_HOOKS
|
|
1029
|
-
self.assertEqual(s["name"], "xray_hooks")
|
|
1030
|
-
self.assertIn("action", s["parameters"]["properties"])
|
|
1031
|
-
self.assertIn("hooks", s["parameters"]["properties"])
|
|
1032
|
-
self.assertIn("action", s["parameters"]["required"])
|
|
1033
|
-
|
|
1034
|
-
def test_action_enum(self):
|
|
1035
|
-
s = schemas.XRAY_HOOKS
|
|
1036
|
-
action = s["parameters"]["properties"]["action"]
|
|
1037
|
-
self.assertIn("install", action["enum"])
|
|
1038
|
-
self.assertIn("uninstall", action["enum"])
|
|
1039
|
-
self.assertIn("list", action["enum"])
|
|
1040
|
-
self.assertIn("status", action["enum"])
|
|
1041
|
-
|
|
1042
|
-
def test_hooks_enum(self):
|
|
1043
|
-
s = schemas.XRAY_HOOKS
|
|
1044
|
-
hooks = s["parameters"]["properties"]["hooks"]
|
|
1045
|
-
self.assertIn("pre-commit", hooks["items"]["enum"])
|
|
1046
|
-
self.assertIn("post-commit", hooks["items"]["enum"])
|
|
1047
|
-
self.assertIn("pre-push", hooks["items"]["enum"])
|
|
1048
|
-
self.assertIn("post-push", hooks["items"]["enum"])
|
|
1049
|
-
|
|
1050
|
-
def test_description_mentions_hooks(self):
|
|
1051
|
-
s = schemas.XRAY_HOOKS
|
|
1052
|
-
self.assertIn("git hooks", s["description"])
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
class TestRegisterIntegrationV2_1(unittest.TestCase):
|
|
1056
|
-
"""Test that register() wires all 4 tools and 2 hooks in v2.2."""
|
|
1057
|
-
|
|
1058
|
-
def test_wires_four_tools(self):
|
|
1059
|
-
ctx = MagicMock()
|
|
1060
|
-
pi.register(ctx)
|
|
1061
|
-
names = [c[1]["name"] for c in ctx.register_tool.call_args_list]
|
|
1062
|
-
self.assertEqual(set(names), {
|
|
1063
|
-
"xray_validate", "xray_codex_check",
|
|
1064
|
-
"xray_health", "xray_hooks",
|
|
1065
|
-
})
|
|
1066
|
-
|
|
1067
|
-
def test_xray_hooks_schema_wired(self):
|
|
1068
|
-
ctx = MagicMock()
|
|
1069
|
-
pi.register(ctx)
|
|
1070
|
-
sm = {c[1]["name"]: c[1]["schema"] for c in ctx.register_tool.call_args_list}
|
|
1071
|
-
self.assertIs(sm["xray_hooks"], schemas.XRAY_HOOKS)
|
|
1072
|
-
|
|
1073
|
-
def test_xray_hooks_handler_wired(self):
|
|
1074
|
-
ctx = MagicMock()
|
|
1075
|
-
pi.register(ctx)
|
|
1076
|
-
hm = {c[1]["name"]: c[1]["handler"] for c in ctx.register_tool.call_args_list}
|
|
1077
|
-
self.assertIs(hm["xray_hooks"], tools_mod.xray_hooks)
|
|
1078
|
-
|
|
1079
|
-
def test_registers_two_hooks(self):
|
|
1080
|
-
ctx = MagicMock()
|
|
1081
|
-
pi.register(ctx)
|
|
1082
|
-
hook_names = [c[0][0] for c in ctx.register_hook.call_args_list]
|
|
1083
|
-
self.assertIn("pre_tool_call", hook_names)
|
|
1084
|
-
self.assertIn("post_tool_call", hook_names)
|
|
1085
|
-
|
|
1086
|
-
def test_survives_missing_session_hook(self):
|
|
1087
|
-
"""Session hook should fail gracefully if not supported."""
|
|
1088
|
-
ctx = MagicMock()
|
|
1089
|
-
def side_effect(*args):
|
|
1090
|
-
raise AttributeError("not available")
|
|
1091
|
-
ctx.register_hook.side_effect = [None, None, side_effect]
|
|
1092
|
-
pi.register(ctx) # should not raise
|
|
1093
|
-
|
|
1094
|
-
def test_v2_2_log_message(self):
|
|
1095
|
-
ctx = MagicMock()
|
|
1096
|
-
with self.assertLogs("xray-hermes", level="INFO") as cm:
|
|
1097
|
-
pi.register(ctx)
|
|
1098
|
-
self.assertTrue(any("v2.2" in m for m in cm.output))
|
|
1099
|
-
self.assertTrue(any("4 tools" in m for m in cm.output))
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
if __name__ == "__main__":
|
|
1103
|
-
unittest.main(verbosity=2)
|