@trac3er/oh-my-god 2.0.0 → 2.0.2
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/.claude-plugin/marketplace.json +8 -8
- package/.claude-plugin/plugin.json +5 -4
- package/.claude-plugin/scripts/uninstall.sh +74 -3
- package/.claude-plugin/scripts/update.sh +78 -3
- package/.coveragerc +26 -0
- package/.mcp.json +4 -4
- package/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +27 -0
- package/CONTRIBUTING.md +62 -0
- package/OMG-setup.sh +1201 -355
- package/README.md +77 -56
- package/SECURITY.md +25 -0
- package/agents/__init__.py +1 -0
- package/agents/model_roles.py +196 -0
- package/agents/omg-architect-mode.md +3 -5
- package/agents/omg-backend-engineer.md +3 -5
- package/agents/omg-database-engineer.md +3 -5
- package/agents/omg-frontend-designer.md +4 -5
- package/agents/omg-implement-mode.md +4 -5
- package/agents/omg-infra-engineer.md +3 -5
- package/agents/omg-research-mode.md +4 -6
- package/agents/omg-security-auditor.md +3 -5
- package/agents/omg-testing-engineer.md +3 -5
- package/build/lib/yaml.py +321 -0
- package/commands/OMG:ai-commit.md +101 -14
- package/commands/OMG:arch.md +302 -19
- package/commands/OMG:ccg.md +12 -7
- package/commands/OMG:compat.md +25 -17
- package/commands/OMG:cost.md +173 -13
- package/commands/OMG:crazy.md +1 -1
- package/commands/OMG:create-agent.md +170 -20
- package/commands/OMG:deps.md +235 -17
- package/commands/OMG:domain-init.md +1 -1
- package/commands/OMG:escalate.md +41 -12
- package/commands/OMG:health-check.md +37 -13
- package/commands/OMG:init.md +122 -14
- package/commands/OMG:project-init.md +1 -1
- package/commands/OMG:session-branch.md +76 -9
- package/commands/OMG:session-fork.md +42 -5
- package/commands/OMG:session-merge.md +124 -8
- package/commands/OMG:setup.md +69 -12
- package/commands/OMG:stats.md +215 -14
- package/commands/OMG:teams.md +19 -10
- package/config/lsp_languages.yaml +8 -0
- package/hooks/__init__.py +0 -0
- package/hooks/_agent_registry.py +423 -0
- package/hooks/_analytics.py +291 -0
- package/hooks/_budget.py +31 -0
- package/hooks/_common.py +569 -0
- package/hooks/_compression_optimizer.py +119 -0
- package/hooks/_cost_ledger.py +176 -0
- package/hooks/_learnings.py +126 -0
- package/hooks/_memory.py +103 -0
- package/hooks/_protected_context.py +150 -0
- package/hooks/_token_counter.py +221 -0
- package/hooks/branch_manager.py +236 -0
- package/hooks/budget_governor.py +232 -0
- package/hooks/circuit-breaker.py +270 -0
- package/hooks/compression_feedback.py +254 -0
- package/hooks/config-guard.py +216 -0
- package/hooks/context_pressure.py +53 -0
- package/hooks/credential_store.py +1020 -0
- package/hooks/fetch-rate-limits.py +212 -0
- package/hooks/firewall.py +48 -0
- package/hooks/hashline-formatter-bridge.py +224 -0
- package/hooks/hashline-injector.py +273 -0
- package/hooks/hashline-validator.py +216 -0
- package/hooks/idle-detector.py +95 -0
- package/hooks/intentgate-keyword-detector.py +188 -0
- package/hooks/magic-keyword-router.py +195 -0
- package/hooks/policy_engine.py +505 -0
- package/hooks/post-tool-failure.py +19 -0
- package/hooks/post-write.py +219 -0
- package/hooks/post_write.py +46 -0
- package/hooks/pre-compact.py +398 -0
- package/hooks/pre-tool-inject.py +98 -0
- package/hooks/prompt-enhancer.py +672 -0
- package/hooks/quality-runner.py +191 -0
- package/hooks/query.py +512 -0
- package/hooks/secret-guard.py +61 -0
- package/hooks/secret_audit.py +144 -0
- package/hooks/session-end-capture.py +137 -0
- package/hooks/session-start.py +277 -0
- package/hooks/setup_wizard.py +582 -0
- package/hooks/shadow_manager.py +297 -0
- package/hooks/state_migration.py +225 -0
- package/hooks/stop-gate.py +7 -0
- package/hooks/stop_dispatcher.py +945 -0
- package/hooks/test-validator.py +361 -0
- package/hooks/test_generator_hook.py +123 -0
- package/hooks/todo-state-tracker.py +114 -0
- package/hooks/tool-ledger.py +149 -0
- package/hooks/trust_review.py +585 -0
- package/hud/omg-hud.mjs +31 -1
- package/lab/__init__.py +1 -0
- package/lab/pipeline.py +75 -0
- package/lab/policies.py +52 -0
- package/package.json +7 -18
- package/plugins/README.md +33 -61
- package/plugins/advanced/commands/OMG:deep-plan.md +3 -3
- package/plugins/advanced/commands/OMG:learn.md +1 -1
- package/plugins/advanced/commands/OMG:security-review.md +3 -3
- package/plugins/advanced/commands/OMG:ship.md +1 -1
- package/plugins/advanced/plugin.json +1 -1
- package/plugins/core/plugin.json +8 -3
- package/plugins/dephealth/__init__.py +0 -0
- package/plugins/dephealth/cve_scanner.py +188 -0
- package/plugins/dephealth/license_checker.py +135 -0
- package/plugins/dephealth/manifest_detector.py +423 -0
- package/plugins/dephealth/vuln_analyzer.py +169 -0
- package/plugins/testgen/__init__.py +0 -0
- package/plugins/testgen/codamosa_engine.py +402 -0
- package/plugins/testgen/edge_case_synthesizer.py +184 -0
- package/plugins/testgen/framework_detector.py +271 -0
- package/plugins/testgen/skeleton_generator.py +219 -0
- package/plugins/viz/__init__.py +0 -0
- package/plugins/viz/ast_parser.py +139 -0
- package/plugins/viz/diagram_generator.py +192 -0
- package/plugins/viz/graph_builder.py +444 -0
- package/plugins/viz/native_parsers.py +259 -0
- package/plugins/viz/regex_parser.py +112 -0
- package/pyproject.toml +81 -0
- package/rules/contextual/write-verify.md +2 -2
- package/rules/core/00-truth.md +1 -1
- package/rules/core/01-surgical.md +1 -1
- package/rules/core/02-circuit-breaker.md +2 -2
- package/rules/core/03-ensemble.md +3 -3
- package/rules/core/04-testing.md +3 -3
- package/runtime/__init__.py +32 -0
- package/runtime/adapters/__init__.py +13 -0
- package/runtime/adapters/claude.py +60 -0
- package/runtime/adapters/gpt.py +53 -0
- package/runtime/adapters/local.py +53 -0
- package/runtime/adoption.py +212 -0
- package/runtime/business_workflow.py +220 -0
- package/runtime/cli_provider.py +85 -0
- package/runtime/compat.py +1299 -0
- package/runtime/custom_agent_loader.py +366 -0
- package/runtime/dispatcher.py +47 -0
- package/runtime/ecosystem.py +371 -0
- package/runtime/legacy_compat.py +7 -0
- package/runtime/mcp_config_writers.py +115 -0
- package/runtime/mcp_lifecycle.py +153 -0
- package/runtime/mcp_memory_server.py +135 -0
- package/runtime/memory_parsers/__init__.py +0 -0
- package/runtime/memory_parsers/chatgpt_parser.py +257 -0
- package/runtime/memory_parsers/claude_import.py +107 -0
- package/runtime/memory_parsers/export.py +97 -0
- package/runtime/memory_parsers/gemini_import.py +91 -0
- package/runtime/memory_parsers/kimi_import.py +91 -0
- package/runtime/memory_store.py +215 -0
- package/runtime/omc_compat.py +7 -0
- package/runtime/providers/__init__.py +0 -0
- package/runtime/providers/codex_provider.py +112 -0
- package/runtime/providers/gemini_provider.py +128 -0
- package/runtime/providers/kimi_provider.py +151 -0
- package/runtime/providers/opencode_provider.py +144 -0
- package/runtime/subagent_dispatcher.py +362 -0
- package/runtime/team_router.py +1167 -0
- package/runtime/tmux_session_manager.py +169 -0
- package/scripts/check-omg-compat-contract-snapshot.py +137 -0
- package/scripts/check-omg-contract-snapshot.py +12 -0
- package/scripts/check-omg-public-ready.py +193 -0
- package/scripts/check-omg-standalone-clean.py +103 -0
- package/scripts/legacy_to_omg_migrate.py +29 -0
- package/scripts/migrate-legacy.py +464 -0
- package/scripts/omc_to_omg_migrate.py +12 -0
- package/scripts/omg.py +492 -0
- package/scripts/settings-merge.py +283 -0
- package/scripts/verify-standalone.sh +8 -4
- package/settings.json +126 -29
- package/templates/profile.yaml +1 -1
- package/tools/__init__.py +2 -0
- package/tools/browser_consent.py +289 -0
- package/tools/browser_stealth.py +481 -0
- package/tools/browser_tool.py +448 -0
- package/tools/changelog_generator.py +347 -0
- package/tools/commit_splitter.py +746 -0
- package/tools/config_discovery.py +151 -0
- package/tools/config_merger.py +449 -0
- package/tools/dashboard_generator.py +300 -0
- package/tools/git_inspector.py +298 -0
- package/tools/lsp_client.py +275 -0
- package/tools/lsp_discovery.py +231 -0
- package/tools/lsp_operations.py +392 -0
- package/tools/pr_generator.py +404 -0
- package/tools/python_repl.py +656 -0
- package/tools/python_sandbox.py +609 -0
- package/tools/search_providers/__init__.py +77 -0
- package/tools/search_providers/brave.py +115 -0
- package/tools/search_providers/exa.py +116 -0
- package/tools/search_providers/jina.py +104 -0
- package/tools/search_providers/perplexity.py +139 -0
- package/tools/search_providers/synthetic.py +74 -0
- package/tools/session_snapshot.py +736 -0
- package/tools/ssh_manager.py +912 -0
- package/tools/theme_engine.py +294 -0
- package/tools/theme_selector.py +137 -0
- package/tools/web_search.py +622 -0
- package/yaml.py +321 -0
- package/.claude-plugin/scripts/install.sh +0 -9
- package/bun.lock +0 -23
- package/bunfig.toml +0 -3
- package/hooks/_budget.ts +0 -1
- package/hooks/_common.ts +0 -63
- package/hooks/circuit-breaker.ts +0 -101
- package/hooks/config-guard.ts +0 -4
- package/hooks/firewall.ts +0 -20
- package/hooks/policy_engine.ts +0 -156
- package/hooks/post-tool-failure.ts +0 -22
- package/hooks/post-write.ts +0 -4
- package/hooks/pre-tool-inject.ts +0 -4
- package/hooks/prompt-enhancer.ts +0 -46
- package/hooks/quality-runner.ts +0 -24
- package/hooks/secret-guard.ts +0 -4
- package/hooks/session-end-capture.ts +0 -19
- package/hooks/session-start.ts +0 -19
- package/hooks/shadow_manager.ts +0 -81
- package/hooks/stop-gate.ts +0 -22
- package/hooks/stop_dispatcher.ts +0 -147
- package/hooks/test-generator-hook.ts +0 -4
- package/hooks/tool-ledger.ts +0 -27
- package/hooks/trust_review.ts +0 -175
- package/lab/pipeline.ts +0 -75
- package/lab/policies.ts +0 -68
- package/runtime/common.ts +0 -111
- package/runtime/compat.ts +0 -174
- package/runtime/dispatcher.ts +0 -25
- package/runtime/ecosystem.ts +0 -186
- package/runtime/provider_bootstrap.ts +0 -99
- package/runtime/provider_smoke.ts +0 -34
- package/runtime/release_readiness.ts +0 -186
- package/runtime/team_router.ts +0 -144
- package/scripts/check-omg-compat-contract-snapshot.ts +0 -20
- package/scripts/check-omg-standalone-clean.ts +0 -12
- package/scripts/check-runtime-clean.ts +0 -94
- package/scripts/omg.ts +0 -352
- package/scripts/settings-merge.ts +0 -93
- package/tools/commit_splitter.ts +0 -23
- package/tools/git_inspector.ts +0 -18
- package/tools/session_snapshot.ts +0 -47
- package/trac3er-oh-my-god-2.0.0.tgz +0 -0
- package/tsconfig.json +0 -15
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Framework Detector — detect test frameworks from project configuration files.
|
|
3
|
+
|
|
4
|
+
Scans a project directory for known config files (package.json, pyproject.toml,
|
|
5
|
+
setup.cfg, Cargo.toml, go.mod, Gemfile) and returns a FrameworkInfo dataclass
|
|
6
|
+
describing the detected framework(s).
|
|
7
|
+
|
|
8
|
+
Feature flag: TEST_GENERATION (default False) — detection works regardless.
|
|
9
|
+
Stdlib only: json, pathlib, re, dataclasses.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class FrameworkInfo:
|
|
19
|
+
"""Describes a detected test framework and its conventions."""
|
|
20
|
+
|
|
21
|
+
framework: str = "unknown"
|
|
22
|
+
config_file: str = ""
|
|
23
|
+
test_dir: str = ""
|
|
24
|
+
assertion_style: str = ""
|
|
25
|
+
mock_library: str = ""
|
|
26
|
+
multi_framework: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- JS/TS framework metadata ---
|
|
30
|
+
|
|
31
|
+
_JS_FRAMEWORKS = {
|
|
32
|
+
"vitest": {
|
|
33
|
+
"framework": "vitest",
|
|
34
|
+
"assertion_style": "expect",
|
|
35
|
+
"mock_library": "vi.mock",
|
|
36
|
+
"test_dir": "__tests__",
|
|
37
|
+
},
|
|
38
|
+
"jest": {
|
|
39
|
+
"framework": "jest",
|
|
40
|
+
"assertion_style": "expect",
|
|
41
|
+
"mock_library": "jest.mock",
|
|
42
|
+
"test_dir": "__tests__",
|
|
43
|
+
},
|
|
44
|
+
"mocha": {
|
|
45
|
+
"framework": "mocha",
|
|
46
|
+
"assertion_style": "assert",
|
|
47
|
+
"mock_library": "sinon",
|
|
48
|
+
"test_dir": "test",
|
|
49
|
+
},
|
|
50
|
+
"@playwright/test": {
|
|
51
|
+
"framework": "playwright",
|
|
52
|
+
"assertion_style": "expect",
|
|
53
|
+
"mock_library": "",
|
|
54
|
+
"test_dir": "tests",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# Detection priority: vitest > jest > mocha (vitest first because projects
|
|
59
|
+
# migrating from jest often keep jest in devDeps alongside vitest)
|
|
60
|
+
_JS_PRIORITY = ["vitest", "jest", "mocha", "@playwright/test"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _detect_from_package_json(project_dir: str) -> FrameworkInfo | None:
|
|
64
|
+
"""Detect JS/TS framework from package.json devDependencies and scripts."""
|
|
65
|
+
pkg_path = Path(project_dir) / "package.json"
|
|
66
|
+
if not pkg_path.is_file():
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
with open(pkg_path, "r", encoding="utf-8") as f:
|
|
71
|
+
pkg = json.load(f)
|
|
72
|
+
except (json.JSONDecodeError, OSError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
dev_deps = pkg.get("devDependencies", {})
|
|
76
|
+
deps = pkg.get("dependencies", {})
|
|
77
|
+
scripts = pkg.get("scripts", {})
|
|
78
|
+
all_deps = {**deps, **dev_deps}
|
|
79
|
+
|
|
80
|
+
# Also check scripts for framework names
|
|
81
|
+
scripts_text = " ".join(str(v) for v in scripts.values())
|
|
82
|
+
|
|
83
|
+
detected = []
|
|
84
|
+
for fw_key in _JS_PRIORITY:
|
|
85
|
+
if fw_key in all_deps:
|
|
86
|
+
detected.append(fw_key)
|
|
87
|
+
elif fw_key.lstrip("@").split("/")[-1] in scripts_text:
|
|
88
|
+
detected.append(fw_key)
|
|
89
|
+
|
|
90
|
+
if not detected:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
primary_key = detected[0]
|
|
94
|
+
meta = _JS_FRAMEWORKS[primary_key]
|
|
95
|
+
|
|
96
|
+
multi = []
|
|
97
|
+
if len(detected) > 1:
|
|
98
|
+
multi = [_JS_FRAMEWORKS[k]["framework"] for k in detected]
|
|
99
|
+
|
|
100
|
+
return FrameworkInfo(
|
|
101
|
+
framework=meta["framework"],
|
|
102
|
+
config_file="package.json",
|
|
103
|
+
test_dir=meta["test_dir"],
|
|
104
|
+
assertion_style=meta["assertion_style"],
|
|
105
|
+
mock_library=meta["mock_library"],
|
|
106
|
+
multi_framework=multi,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _detect_from_pyproject_toml(project_dir: str) -> FrameworkInfo | None:
|
|
111
|
+
"""Detect pytest from pyproject.toml [tool.pytest.*] section."""
|
|
112
|
+
path = Path(project_dir) / "pyproject.toml"
|
|
113
|
+
if not path.is_file():
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
content = path.read_text(encoding="utf-8")
|
|
118
|
+
except OSError:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
if re.search(r"\[tool\.pytest", content):
|
|
122
|
+
# Try to extract testpaths
|
|
123
|
+
test_dir = "tests"
|
|
124
|
+
m = re.search(r'testpaths\s*=\s*\["?([^"\]\s]+)', content)
|
|
125
|
+
if m:
|
|
126
|
+
test_dir = m.group(1)
|
|
127
|
+
|
|
128
|
+
return FrameworkInfo(
|
|
129
|
+
framework="pytest",
|
|
130
|
+
config_file="pyproject.toml",
|
|
131
|
+
test_dir=test_dir,
|
|
132
|
+
assertion_style="assert",
|
|
133
|
+
mock_library="unittest.mock",
|
|
134
|
+
multi_framework=[],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Check for pytest in dependencies
|
|
138
|
+
if re.search(r"pytest", content):
|
|
139
|
+
return FrameworkInfo(
|
|
140
|
+
framework="pytest",
|
|
141
|
+
config_file="pyproject.toml",
|
|
142
|
+
test_dir="tests",
|
|
143
|
+
assertion_style="assert",
|
|
144
|
+
mock_library="unittest.mock",
|
|
145
|
+
multi_framework=[],
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _detect_from_setup_cfg(project_dir: str) -> FrameworkInfo | None:
|
|
152
|
+
"""Detect pytest from setup.cfg [tool:pytest] section."""
|
|
153
|
+
path = Path(project_dir) / "setup.cfg"
|
|
154
|
+
if not path.is_file():
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
content = path.read_text(encoding="utf-8")
|
|
159
|
+
except OSError:
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
if re.search(r"\[tool:pytest\]", content):
|
|
163
|
+
test_dir = "tests"
|
|
164
|
+
m = re.search(r"testpaths\s*=\s*(\S+)", content)
|
|
165
|
+
if m:
|
|
166
|
+
test_dir = m.group(1)
|
|
167
|
+
|
|
168
|
+
return FrameworkInfo(
|
|
169
|
+
framework="pytest",
|
|
170
|
+
config_file="setup.cfg",
|
|
171
|
+
test_dir=test_dir,
|
|
172
|
+
assertion_style="assert",
|
|
173
|
+
mock_library="unittest.mock",
|
|
174
|
+
multi_framework=[],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _detect_from_go_mod(project_dir: str) -> FrameworkInfo | None:
|
|
181
|
+
"""Detect Go test from go.mod presence."""
|
|
182
|
+
path = Path(project_dir) / "go.mod"
|
|
183
|
+
if not path.is_file():
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
return FrameworkInfo(
|
|
187
|
+
framework="go test",
|
|
188
|
+
config_file="go.mod",
|
|
189
|
+
test_dir=".",
|
|
190
|
+
assertion_style="testing.T",
|
|
191
|
+
mock_library="testify/mock",
|
|
192
|
+
multi_framework=[],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _detect_from_cargo_toml(project_dir: str) -> FrameworkInfo | None:
|
|
197
|
+
"""Detect Rust/cargo test from Cargo.toml presence."""
|
|
198
|
+
path = Path(project_dir) / "Cargo.toml"
|
|
199
|
+
if not path.is_file():
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
return FrameworkInfo(
|
|
203
|
+
framework="cargo test",
|
|
204
|
+
config_file="Cargo.toml",
|
|
205
|
+
test_dir="tests",
|
|
206
|
+
assertion_style="assert!",
|
|
207
|
+
mock_library="mockall",
|
|
208
|
+
multi_framework=[],
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _detect_from_gemfile(project_dir: str) -> FrameworkInfo | None:
|
|
213
|
+
"""Detect RSpec from Gemfile containing 'rspec'."""
|
|
214
|
+
path = Path(project_dir) / "Gemfile"
|
|
215
|
+
if not path.is_file():
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
content = path.read_text(encoding="utf-8")
|
|
220
|
+
except OSError:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
if re.search(r"['\"]rspec['\"]", content):
|
|
224
|
+
return FrameworkInfo(
|
|
225
|
+
framework="rspec",
|
|
226
|
+
config_file="Gemfile",
|
|
227
|
+
test_dir="spec",
|
|
228
|
+
assertion_style="expect",
|
|
229
|
+
mock_library="rspec-mocks",
|
|
230
|
+
multi_framework=[],
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# Ordered detector chain — first match wins (except multi-framework from JS)
|
|
237
|
+
_DETECTORS = [
|
|
238
|
+
_detect_from_package_json,
|
|
239
|
+
_detect_from_pyproject_toml,
|
|
240
|
+
_detect_from_setup_cfg,
|
|
241
|
+
_detect_from_go_mod,
|
|
242
|
+
_detect_from_cargo_toml,
|
|
243
|
+
_detect_from_gemfile,
|
|
244
|
+
]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def detect_test_framework(project_dir: str) -> FrameworkInfo:
|
|
248
|
+
"""Detect test framework(s) in a project directory.
|
|
249
|
+
|
|
250
|
+
Scans known config files in priority order. Returns FrameworkInfo with
|
|
251
|
+
framework='unknown' if nothing detected (never crashes).
|
|
252
|
+
|
|
253
|
+
Feature flag TEST_GENERATION gates downstream generation — detection
|
|
254
|
+
itself always works.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
project_dir: Absolute or relative path to the project root.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
FrameworkInfo dataclass with detected framework metadata.
|
|
261
|
+
"""
|
|
262
|
+
try:
|
|
263
|
+
for detector in _DETECTORS:
|
|
264
|
+
result = detector(project_dir)
|
|
265
|
+
if result is not None:
|
|
266
|
+
return result
|
|
267
|
+
except Exception:
|
|
268
|
+
# Crash isolation: return unknown on any unexpected error
|
|
269
|
+
pass
|
|
270
|
+
|
|
271
|
+
return FrameworkInfo()
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Generate lightweight test skeletons from source files.
|
|
2
|
+
|
|
3
|
+
Python symbol extraction uses ``ast`` for precise parsing of top-level public
|
|
4
|
+
functions, classes, and class methods.
|
|
5
|
+
|
|
6
|
+
JavaScript/TypeScript symbol extraction uses regular expressions for exported
|
|
7
|
+
declarations (``export function``, ``export const``, ``export default
|
|
8
|
+
function``, ``export class``). This regex approach is intentionally lightweight
|
|
9
|
+
and expected to be about 70-80% accurate for common code styles.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_RE_JS_EXPORT_FUNCTION = re.compile(r"(?:^|\s)export\s+function\s+([A-Za-z_$][\w$]*)\s*\(")
|
|
20
|
+
_RE_JS_EXPORT_CONST = re.compile(r"(?:^|\s)export\s+const\s+([A-Za-z_$][\w$]*)\s*=")
|
|
21
|
+
_RE_JS_EXPORT_DEFAULT_FUNCTION = re.compile(
|
|
22
|
+
r"(?:^|\s)export\s+default\s+function(?:\s+([A-Za-z_$][\w$]*))?\s*\("
|
|
23
|
+
)
|
|
24
|
+
_RE_JS_EXPORT_CLASS = re.compile(r"(?:^|\s)export\s+class\s+([A-Za-z_$][\w$]*)\b")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def generate_test_skeleton(source_file: str, framework_info: dict[str, object]) -> str:
|
|
28
|
+
path = Path(source_file)
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return ""
|
|
31
|
+
|
|
32
|
+
source_text = path.read_text(encoding="utf-8")
|
|
33
|
+
if not source_text.strip():
|
|
34
|
+
return ""
|
|
35
|
+
|
|
36
|
+
framework = str(framework_info.get("framework", "")).lower()
|
|
37
|
+
|
|
38
|
+
functions: list[str]
|
|
39
|
+
classes: dict[str, list[str]]
|
|
40
|
+
if path.suffix == ".py":
|
|
41
|
+
functions, classes = _extract_python_symbols(source_text)
|
|
42
|
+
else:
|
|
43
|
+
functions, classes = _extract_js_ts_symbols(source_text)
|
|
44
|
+
|
|
45
|
+
if framework == "pytest":
|
|
46
|
+
return _render_pytest(functions, classes)
|
|
47
|
+
if framework in {"jest", "vitest"}:
|
|
48
|
+
return _render_jest_vitest(functions, classes)
|
|
49
|
+
if framework == "go":
|
|
50
|
+
return _render_go(functions, classes)
|
|
51
|
+
return _render_generic(functions, classes)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _extract_python_symbols(source_text: str) -> tuple[list[str], dict[str, list[str]]]:
|
|
55
|
+
functions: list[str] = []
|
|
56
|
+
classes: dict[str, list[str]] = {}
|
|
57
|
+
|
|
58
|
+
tree = ast.parse(source_text)
|
|
59
|
+
for node in tree.body:
|
|
60
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_"):
|
|
61
|
+
functions.append(node.name)
|
|
62
|
+
elif isinstance(node, ast.ClassDef) and not node.name.startswith("_"):
|
|
63
|
+
public_methods: list[str] = []
|
|
64
|
+
for member in node.body:
|
|
65
|
+
if isinstance(member, (ast.FunctionDef, ast.AsyncFunctionDef)) and not member.name.startswith("_"):
|
|
66
|
+
public_methods.append(member.name)
|
|
67
|
+
classes[node.name] = public_methods
|
|
68
|
+
|
|
69
|
+
return functions, classes
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _extract_js_ts_symbols(source_text: str) -> tuple[list[str], dict[str, list[str]]]:
|
|
73
|
+
functions: list[str] = []
|
|
74
|
+
classes: dict[str, list[str]] = {}
|
|
75
|
+
|
|
76
|
+
functions.extend(_RE_JS_EXPORT_FUNCTION.findall(source_text))
|
|
77
|
+
functions.extend(_RE_JS_EXPORT_CONST.findall(source_text))
|
|
78
|
+
|
|
79
|
+
for match in _RE_JS_EXPORT_DEFAULT_FUNCTION.findall(source_text):
|
|
80
|
+
functions.append(match or "defaultExport")
|
|
81
|
+
|
|
82
|
+
for class_name in _RE_JS_EXPORT_CLASS.findall(source_text):
|
|
83
|
+
classes[class_name] = []
|
|
84
|
+
|
|
85
|
+
return _dedupe(functions), classes
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _render_pytest(functions: list[str], classes: dict[str, list[str]]) -> str:
|
|
89
|
+
lines: list[str] = []
|
|
90
|
+
|
|
91
|
+
for func_name in functions:
|
|
92
|
+
lines.extend(
|
|
93
|
+
[
|
|
94
|
+
f"def test_{func_name}_happy_path():",
|
|
95
|
+
" # TODO: implement test",
|
|
96
|
+
" # happy path",
|
|
97
|
+
" assert True",
|
|
98
|
+
"",
|
|
99
|
+
f"def test_{func_name}_error_case():",
|
|
100
|
+
" # TODO: implement test",
|
|
101
|
+
" # error case",
|
|
102
|
+
" assert True",
|
|
103
|
+
"",
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for class_name, methods in classes.items():
|
|
108
|
+
lines.append(f"class Test{class_name}:")
|
|
109
|
+
if not methods:
|
|
110
|
+
lines.extend([" # TODO: implement test", " pass", ""])
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
for method_name in methods:
|
|
114
|
+
lines.extend(
|
|
115
|
+
[
|
|
116
|
+
f" def test_{method_name}_happy_path(self):",
|
|
117
|
+
" # TODO: implement test",
|
|
118
|
+
" # happy path",
|
|
119
|
+
" assert True",
|
|
120
|
+
"",
|
|
121
|
+
f" def test_{method_name}_error_case(self):",
|
|
122
|
+
" # TODO: implement test",
|
|
123
|
+
" # error case",
|
|
124
|
+
" assert True",
|
|
125
|
+
"",
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return "\n".join(lines).rstrip()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _render_jest_vitest(functions: list[str], classes: dict[str, list[str]]) -> str:
|
|
133
|
+
names = functions + list(classes.keys())
|
|
134
|
+
blocks: list[str] = []
|
|
135
|
+
|
|
136
|
+
for name in names:
|
|
137
|
+
blocks.extend(
|
|
138
|
+
[
|
|
139
|
+
f"describe('{name}', () => {{",
|
|
140
|
+
" it('should happy path', () => {",
|
|
141
|
+
" // TODO: implement test",
|
|
142
|
+
" // happy path",
|
|
143
|
+
" expect(value).toBe(expected);",
|
|
144
|
+
" });",
|
|
145
|
+
"",
|
|
146
|
+
" it('should error case', () => {",
|
|
147
|
+
" // TODO: implement test",
|
|
148
|
+
" // error case",
|
|
149
|
+
" expect(value).toBe(expected);",
|
|
150
|
+
" });",
|
|
151
|
+
"});",
|
|
152
|
+
"",
|
|
153
|
+
]
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return "\n".join(blocks).rstrip()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _render_go(functions: list[str], classes: dict[str, list[str]]) -> str:
|
|
160
|
+
names = functions + list(classes.keys())
|
|
161
|
+
if not names:
|
|
162
|
+
return ""
|
|
163
|
+
|
|
164
|
+
lines = [
|
|
165
|
+
"package main",
|
|
166
|
+
"",
|
|
167
|
+
'import "testing"',
|
|
168
|
+
"",
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
for name in names:
|
|
172
|
+
test_name = _to_pascal_case(name)
|
|
173
|
+
lines.extend(
|
|
174
|
+
[
|
|
175
|
+
f"func Test{test_name}(t *testing.T) {{",
|
|
176
|
+
" // TODO: implement test",
|
|
177
|
+
" // happy path",
|
|
178
|
+
" // error case",
|
|
179
|
+
"}",
|
|
180
|
+
"",
|
|
181
|
+
]
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return "\n".join(lines).rstrip()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _render_generic(functions: list[str], classes: dict[str, list[str]]) -> str:
|
|
188
|
+
names = functions + list(classes.keys())
|
|
189
|
+
if not names:
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
lines = []
|
|
193
|
+
for name in names:
|
|
194
|
+
lines.extend(
|
|
195
|
+
[
|
|
196
|
+
f"# Test skeleton for {name}",
|
|
197
|
+
"# TODO: implement test",
|
|
198
|
+
"# happy path",
|
|
199
|
+
"# error case",
|
|
200
|
+
"",
|
|
201
|
+
]
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return "\n".join(lines).rstrip()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _dedupe(values: list[str]) -> list[str]:
|
|
208
|
+
seen: set[str] = set()
|
|
209
|
+
ordered: list[str] = []
|
|
210
|
+
for value in values:
|
|
211
|
+
if value not in seen:
|
|
212
|
+
seen.add(value)
|
|
213
|
+
ordered.append(value)
|
|
214
|
+
return ordered
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _to_pascal_case(name: str) -> str:
|
|
218
|
+
parts = re.split(r"[^A-Za-z0-9]+", name)
|
|
219
|
+
return "".join(part[:1].upper() + part[1:] for part in parts if part) or "Generated"
|
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import override
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse_python_imports(file_path: str) -> list[str]:
|
|
10
|
+
source = Path(file_path).read_text(encoding="utf-8")
|
|
11
|
+
tree = ast.parse(source)
|
|
12
|
+
collector = _ImportCollector()
|
|
13
|
+
collector.visit(tree)
|
|
14
|
+
return collector.imports
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_dependency_graph(project_dir: str) -> dict[str, object]:
|
|
18
|
+
root = Path(project_dir)
|
|
19
|
+
py_files = sorted(root.rglob("*.py"))
|
|
20
|
+
|
|
21
|
+
graph: dict[str, list[str]] = {}
|
|
22
|
+
for py_file in py_files:
|
|
23
|
+
module_name = _module_name_for_path(root, py_file)
|
|
24
|
+
imports = parse_python_imports(str(py_file))
|
|
25
|
+
graph[module_name] = _dedupe_preserve_order(imports)
|
|
26
|
+
|
|
27
|
+
cycles = _detect_cycles(graph)
|
|
28
|
+
stats = {
|
|
29
|
+
"module_count": len(graph),
|
|
30
|
+
"edge_count": sum(len(edges) for edges in graph.values()),
|
|
31
|
+
"max_depth": _compute_max_depth(graph),
|
|
32
|
+
"has_cycles": bool(cycles),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
payload: dict[str, object] = {
|
|
36
|
+
"graph": graph,
|
|
37
|
+
"cycles": cycles,
|
|
38
|
+
"stats": stats,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
output_path = root / ".omg" / "state" / "dependency-graph.json"
|
|
42
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
_ = output_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
44
|
+
|
|
45
|
+
return payload
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _ImportCollector(ast.NodeVisitor):
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self.imports: list[str] = []
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
def visit_Import(self, node: ast.Import) -> None: # noqa: N802
|
|
54
|
+
for alias in node.names:
|
|
55
|
+
self.imports.append(alias.name)
|
|
56
|
+
|
|
57
|
+
@override
|
|
58
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: N802
|
|
59
|
+
if node.level > 0:
|
|
60
|
+
prefix = "." * node.level
|
|
61
|
+
self.imports.append(f"{prefix}{node.module or ''}")
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
if node.module:
|
|
65
|
+
self.imports.append(node.module)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _module_name_for_path(root: Path, py_file: Path) -> str:
|
|
69
|
+
rel = py_file.relative_to(root).with_suffix("")
|
|
70
|
+
parts = list(rel.parts)
|
|
71
|
+
if parts and parts[-1] == "__init__":
|
|
72
|
+
parts = parts[:-1]
|
|
73
|
+
return ".".join(parts) if parts else "__init__"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _dedupe_preserve_order(values: list[str]) -> list[str]:
|
|
77
|
+
seen: set[str] = set()
|
|
78
|
+
ordered: list[str] = []
|
|
79
|
+
for value in values:
|
|
80
|
+
if value not in seen:
|
|
81
|
+
seen.add(value)
|
|
82
|
+
ordered.append(value)
|
|
83
|
+
return ordered
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _detect_cycles(graph: dict[str, list[str]]) -> list[list[str]]:
|
|
87
|
+
visited: set[str] = set()
|
|
88
|
+
on_stack: set[str] = set()
|
|
89
|
+
stack: list[str] = []
|
|
90
|
+
cycle_set: set[tuple[str, ...]] = set()
|
|
91
|
+
|
|
92
|
+
def dfs(node: str) -> None:
|
|
93
|
+
visited.add(node)
|
|
94
|
+
on_stack.add(node)
|
|
95
|
+
stack.append(node)
|
|
96
|
+
|
|
97
|
+
for nxt in graph.get(node, []):
|
|
98
|
+
if nxt not in graph:
|
|
99
|
+
continue
|
|
100
|
+
if nxt not in visited:
|
|
101
|
+
dfs(nxt)
|
|
102
|
+
elif nxt in on_stack:
|
|
103
|
+
start_idx = stack.index(nxt)
|
|
104
|
+
cycle = stack[start_idx:] + [nxt]
|
|
105
|
+
cycle_set.add(_canonical_cycle(cycle))
|
|
106
|
+
|
|
107
|
+
_ = stack.pop()
|
|
108
|
+
on_stack.remove(node)
|
|
109
|
+
|
|
110
|
+
for module in graph:
|
|
111
|
+
if module not in visited:
|
|
112
|
+
dfs(module)
|
|
113
|
+
|
|
114
|
+
return [list(cycle) for cycle in sorted(cycle_set)]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _canonical_cycle(cycle: list[str]) -> tuple[str, ...]:
|
|
118
|
+
core = cycle[:-1]
|
|
119
|
+
if not core:
|
|
120
|
+
return tuple(cycle)
|
|
121
|
+
|
|
122
|
+
rotations = [tuple(core[idx:] + core[:idx]) for idx in range(len(core))]
|
|
123
|
+
minimal = min(rotations)
|
|
124
|
+
return minimal + (minimal[0],)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _compute_max_depth(graph: dict[str, list[str]]) -> int:
|
|
128
|
+
def depth(node: str, path: set[str]) -> int:
|
|
129
|
+
best = 0
|
|
130
|
+
for nxt in graph.get(node, []):
|
|
131
|
+
if nxt not in graph or nxt in path:
|
|
132
|
+
continue
|
|
133
|
+
best = max(best, 1 + depth(nxt, path | {nxt}))
|
|
134
|
+
return best
|
|
135
|
+
|
|
136
|
+
best_depth = 0
|
|
137
|
+
for module in graph:
|
|
138
|
+
best_depth = max(best_depth, depth(module, {module}))
|
|
139
|
+
return best_depth
|