@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,192 @@
|
|
|
1
|
+
"""Mermaid and D2 diagram generator for dependency graphs.
|
|
2
|
+
|
|
3
|
+
Converts adjacency-list dependency graphs (from graph_builder.py) into
|
|
4
|
+
Mermaid and D2 diagram text, with optional PNG rendering via mermaid.ink.
|
|
5
|
+
|
|
6
|
+
All functions are crash-isolated: they return empty strings or False on error,
|
|
7
|
+
never raise exceptions to the caller.
|
|
8
|
+
|
|
9
|
+
Feature-gated behind CODEBASE_VIZ for hook integration, but library functions
|
|
10
|
+
work independently of the flag.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Language → color mapping for Mermaid style directives
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
_LANG_COLORS: dict[str, str] = {
|
|
24
|
+
"python": "#4B8BBE",
|
|
25
|
+
"py": "#4B8BBE",
|
|
26
|
+
"js": "#F7DF1E",
|
|
27
|
+
"javascript": "#F7DF1E",
|
|
28
|
+
"ts": "#F7DF1E",
|
|
29
|
+
"typescript": "#F7DF1E",
|
|
30
|
+
"go": "#00ADD8",
|
|
31
|
+
"golang": "#00ADD8",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _sanitize_node_id(name: str) -> str:
|
|
36
|
+
"""Convert a module name to a valid Mermaid/D2 node identifier.
|
|
37
|
+
|
|
38
|
+
Replaces dots and hyphens with underscores to avoid syntax issues.
|
|
39
|
+
"""
|
|
40
|
+
return name.replace(".", "_").replace("-", "_")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Mermaid generation
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def generate_mermaid(
|
|
49
|
+
graph: dict[str, list[str]] | None,
|
|
50
|
+
*,
|
|
51
|
+
zoom: str | None = None,
|
|
52
|
+
language_map: dict[str, str] | None = None,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""Generate Mermaid ``graph TD`` text from an adjacency-list graph.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
graph: Adjacency list ``{module: [dep1, dep2, ...]}``.
|
|
58
|
+
zoom: If provided, return a focused subgraph showing only this
|
|
59
|
+
module and its direct dependencies.
|
|
60
|
+
language_map: Optional mapping of ``{module_name: language}`` for
|
|
61
|
+
color-coding nodes by language.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Mermaid diagram text, or empty string on error/empty input.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
if not isinstance(graph, dict) or not graph:
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
# Zoom: filter to only the target module + its direct deps
|
|
71
|
+
if zoom is not None:
|
|
72
|
+
if zoom not in graph:
|
|
73
|
+
return ""
|
|
74
|
+
deps = graph[zoom]
|
|
75
|
+
filtered: dict[str, list[str]] = {zoom: deps}
|
|
76
|
+
for dep in deps:
|
|
77
|
+
if dep in graph:
|
|
78
|
+
# Include dep node but only edges within the zoom scope
|
|
79
|
+
filtered[dep] = []
|
|
80
|
+
else:
|
|
81
|
+
filtered[dep] = []
|
|
82
|
+
graph = filtered
|
|
83
|
+
|
|
84
|
+
lines: list[str] = ["graph TD"]
|
|
85
|
+
|
|
86
|
+
# Collect all unique nodes (both sources and targets)
|
|
87
|
+
all_nodes: set[str] = set()
|
|
88
|
+
for module, deps in graph.items():
|
|
89
|
+
all_nodes.add(module)
|
|
90
|
+
for dep in deps:
|
|
91
|
+
all_nodes.add(dep)
|
|
92
|
+
|
|
93
|
+
# Emit node definitions
|
|
94
|
+
for node in sorted(all_nodes):
|
|
95
|
+
node_id = _sanitize_node_id(node)
|
|
96
|
+
lines.append(f' {node_id}["{node}"]')
|
|
97
|
+
|
|
98
|
+
# Emit edges
|
|
99
|
+
for module in sorted(graph):
|
|
100
|
+
src_id = _sanitize_node_id(module)
|
|
101
|
+
for dep in graph[module]:
|
|
102
|
+
dst_id = _sanitize_node_id(dep)
|
|
103
|
+
lines.append(f" {src_id} --> {dst_id}")
|
|
104
|
+
|
|
105
|
+
# Emit style directives for language color-coding
|
|
106
|
+
if language_map:
|
|
107
|
+
for node in sorted(all_nodes):
|
|
108
|
+
lang = language_map.get(node)
|
|
109
|
+
if lang:
|
|
110
|
+
color = _LANG_COLORS.get(lang.lower())
|
|
111
|
+
if color:
|
|
112
|
+
node_id = _sanitize_node_id(node)
|
|
113
|
+
lines.append(f" style {node_id} fill:{color}")
|
|
114
|
+
|
|
115
|
+
return "\n".join(lines)
|
|
116
|
+
|
|
117
|
+
except Exception:
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# D2 generation
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def generate_d2(graph: dict[str, list[str]] | None) -> str:
|
|
127
|
+
"""Generate D2 diagram text from an adjacency-list graph.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
graph: Adjacency list ``{module: [dep1, dep2, ...]}``.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
D2 diagram text, or empty string on error/empty input.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
if not isinstance(graph, dict) or not graph:
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
lines: list[str] = []
|
|
140
|
+
emitted_nodes: set[str] = set()
|
|
141
|
+
|
|
142
|
+
for module in sorted(graph):
|
|
143
|
+
deps = graph[module]
|
|
144
|
+
if deps:
|
|
145
|
+
for dep in deps:
|
|
146
|
+
lines.append(f"{module} -> {dep}")
|
|
147
|
+
emitted_nodes.add(module)
|
|
148
|
+
emitted_nodes.add(dep)
|
|
149
|
+
else:
|
|
150
|
+
# Isolated node with no dependencies
|
|
151
|
+
if module not in emitted_nodes:
|
|
152
|
+
lines.append(module)
|
|
153
|
+
emitted_nodes.add(module)
|
|
154
|
+
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
except Exception:
|
|
158
|
+
return ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# PNG rendering via mermaid.ink
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def render_to_png(
|
|
167
|
+
mermaid_text: str | None,
|
|
168
|
+
output_path: str,
|
|
169
|
+
) -> bool:
|
|
170
|
+
"""Render Mermaid text to a PNG file via the mermaid.ink public API.
|
|
171
|
+
|
|
172
|
+
Constructs URL: ``https://mermaid.ink/img/{base64_encoded}``
|
|
173
|
+
Downloads the PNG using ``urllib.request.urlretrieve``.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
mermaid_text: Valid Mermaid diagram text.
|
|
177
|
+
output_path: File path where the PNG will be saved.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True on success, False on any failure (never raises).
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
if not mermaid_text or not isinstance(mermaid_text, str):
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
encoded = base64.urlsafe_b64encode(mermaid_text.encode()).decode()
|
|
187
|
+
url = f"https://mermaid.ink/img/{encoded}"
|
|
188
|
+
urllib.request.urlretrieve(url, output_path)
|
|
189
|
+
return True
|
|
190
|
+
|
|
191
|
+
except Exception:
|
|
192
|
+
return False
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"""Unified multi-language dependency graph builder.
|
|
2
|
+
|
|
3
|
+
Combines Python AST parser and JS/TS/Go regex parsers into
|
|
4
|
+
a single project-wide dependency graph with metrics and persistence.
|
|
5
|
+
|
|
6
|
+
Feature-gated behind CODEBASE_VIZ (default: off).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Language extension mapping
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
_PYTHON_EXTS: frozenset[str] = frozenset({".py"})
|
|
22
|
+
_JS_EXTS: frozenset[str] = frozenset({".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"})
|
|
23
|
+
_GO_EXTS: frozenset[str] = frozenset({".go"})
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Lazy parser imports — crash-safe
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
_project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
30
|
+
if _project_root not in sys.path:
|
|
31
|
+
sys.path.insert(0, _project_root)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _import_parsers() -> tuple[Any, Any]:
|
|
35
|
+
"""Import ast_parser and regex_parser lazily. Returns (ast_mod, regex_mod)."""
|
|
36
|
+
try:
|
|
37
|
+
ast_mod = importlib.import_module("plugins.viz.ast_parser")
|
|
38
|
+
except Exception:
|
|
39
|
+
ast_mod = None
|
|
40
|
+
try:
|
|
41
|
+
regex_mod = importlib.import_module("plugins.viz.regex_parser")
|
|
42
|
+
except Exception:
|
|
43
|
+
regex_mod = None
|
|
44
|
+
return ast_mod, regex_mod
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _get_feature_flag(name: str, default: bool = False) -> bool:
|
|
48
|
+
"""Get feature flag value, importing hooks._common if available."""
|
|
49
|
+
# Environment variable always takes precedence
|
|
50
|
+
env_key = f"OMG_{name.upper()}_ENABLED"
|
|
51
|
+
env_val = os.environ.get(env_key, "").lower()
|
|
52
|
+
if env_val in ("1", "true", "yes"):
|
|
53
|
+
return True
|
|
54
|
+
if env_val in ("0", "false", "no"):
|
|
55
|
+
return False
|
|
56
|
+
# Try hooks._common
|
|
57
|
+
try:
|
|
58
|
+
_common = importlib.import_module("hooks._common")
|
|
59
|
+
return _common.get_feature_flag(name, default)
|
|
60
|
+
except Exception:
|
|
61
|
+
return default
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Module naming
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _module_name_for_path(root: Path, file_path: Path) -> str:
|
|
69
|
+
"""Convert a file path to a dotted module name relative to root."""
|
|
70
|
+
rel = file_path.relative_to(root).with_suffix("")
|
|
71
|
+
parts = list(rel.parts)
|
|
72
|
+
if parts and parts[-1] == "__init__":
|
|
73
|
+
parts = parts[:-1]
|
|
74
|
+
return ".".join(parts) if parts else "__init__"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _dedupe_preserve_order(values: list[str]) -> list[str]:
|
|
78
|
+
"""Deduplicate list while preserving insertion order."""
|
|
79
|
+
seen: set[str] = set()
|
|
80
|
+
ordered: list[str] = []
|
|
81
|
+
for v in values:
|
|
82
|
+
if v not in seen:
|
|
83
|
+
seen.add(v)
|
|
84
|
+
ordered.append(v)
|
|
85
|
+
return ordered
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# File collection
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _collect_files(root: Path) -> dict[str, list[Path]]:
|
|
93
|
+
"""Collect project files by language category, excluding hidden/venv dirs."""
|
|
94
|
+
result: dict[str, list[Path]] = {"python": [], "js": [], "go": []}
|
|
95
|
+
if not root.is_dir():
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
skip_dirs = {".omg", ".git", ".venv", "venv", "node_modules", "__pycache__", ".tox"}
|
|
99
|
+
|
|
100
|
+
for item in sorted(root.rglob("*")):
|
|
101
|
+
if not item.is_file():
|
|
102
|
+
continue
|
|
103
|
+
# Skip hidden/venv directories
|
|
104
|
+
rel_parts = item.relative_to(root).parts
|
|
105
|
+
if any(p in skip_dirs or p.startswith(".") for p in rel_parts[:-1]):
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
ext = item.suffix
|
|
109
|
+
if ext in _PYTHON_EXTS:
|
|
110
|
+
result["python"].append(item)
|
|
111
|
+
elif ext in _JS_EXTS:
|
|
112
|
+
result["js"].append(item)
|
|
113
|
+
elif ext in _GO_EXTS:
|
|
114
|
+
result["go"].append(item)
|
|
115
|
+
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Per-language parsing
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _parse_python_files(
|
|
124
|
+
root: Path,
|
|
125
|
+
files: list[Path],
|
|
126
|
+
ast_mod: Any,
|
|
127
|
+
cached_graph: dict[str, list[str]],
|
|
128
|
+
cached_mtimes: dict[str, float],
|
|
129
|
+
new_mtimes: dict[str, float],
|
|
130
|
+
) -> dict[str, list[str]]:
|
|
131
|
+
"""Parse Python files into adjacency list entries."""
|
|
132
|
+
graph: dict[str, list[str]] = {}
|
|
133
|
+
if ast_mod is None:
|
|
134
|
+
return graph
|
|
135
|
+
|
|
136
|
+
parse_fn = getattr(ast_mod, "parse_python_imports", None)
|
|
137
|
+
if parse_fn is None:
|
|
138
|
+
return graph
|
|
139
|
+
|
|
140
|
+
for py_file in files:
|
|
141
|
+
module_name = _module_name_for_path(root, py_file)
|
|
142
|
+
rel_key = str(py_file.relative_to(root))
|
|
143
|
+
current_mtime = py_file.stat().st_mtime
|
|
144
|
+
|
|
145
|
+
# Incremental: reuse cached result if mtime unchanged
|
|
146
|
+
if (
|
|
147
|
+
rel_key in cached_mtimes
|
|
148
|
+
and cached_mtimes[rel_key] == current_mtime
|
|
149
|
+
and module_name in cached_graph
|
|
150
|
+
):
|
|
151
|
+
graph[module_name] = cached_graph[module_name]
|
|
152
|
+
else:
|
|
153
|
+
try:
|
|
154
|
+
imports = parse_fn(str(py_file))
|
|
155
|
+
graph[module_name] = _dedupe_preserve_order(imports)
|
|
156
|
+
except Exception:
|
|
157
|
+
graph[module_name] = []
|
|
158
|
+
|
|
159
|
+
new_mtimes[rel_key] = current_mtime
|
|
160
|
+
|
|
161
|
+
return graph
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _parse_js_files(
|
|
165
|
+
root: Path,
|
|
166
|
+
files: list[Path],
|
|
167
|
+
regex_mod: Any,
|
|
168
|
+
cached_graph: dict[str, list[str]],
|
|
169
|
+
cached_mtimes: dict[str, float],
|
|
170
|
+
new_mtimes: dict[str, float],
|
|
171
|
+
) -> dict[str, list[str]]:
|
|
172
|
+
"""Parse JS/TS files into adjacency list entries."""
|
|
173
|
+
graph: dict[str, list[str]] = {}
|
|
174
|
+
if regex_mod is None:
|
|
175
|
+
return graph
|
|
176
|
+
|
|
177
|
+
parse_fn = getattr(regex_mod, "parse_js_imports", None)
|
|
178
|
+
if parse_fn is None:
|
|
179
|
+
return graph
|
|
180
|
+
|
|
181
|
+
for js_file in files:
|
|
182
|
+
module_name = _module_name_for_path(root, js_file)
|
|
183
|
+
rel_key = str(js_file.relative_to(root))
|
|
184
|
+
current_mtime = js_file.stat().st_mtime
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
rel_key in cached_mtimes
|
|
188
|
+
and cached_mtimes[rel_key] == current_mtime
|
|
189
|
+
and module_name in cached_graph
|
|
190
|
+
):
|
|
191
|
+
graph[module_name] = cached_graph[module_name]
|
|
192
|
+
else:
|
|
193
|
+
try:
|
|
194
|
+
result = parse_fn(str(js_file))
|
|
195
|
+
graph[module_name] = _dedupe_preserve_order(result.get("imports", []))
|
|
196
|
+
except Exception:
|
|
197
|
+
graph[module_name] = []
|
|
198
|
+
|
|
199
|
+
new_mtimes[rel_key] = current_mtime
|
|
200
|
+
|
|
201
|
+
return graph
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _parse_go_files(
|
|
205
|
+
root: Path,
|
|
206
|
+
files: list[Path],
|
|
207
|
+
regex_mod: Any,
|
|
208
|
+
cached_graph: dict[str, list[str]],
|
|
209
|
+
cached_mtimes: dict[str, float],
|
|
210
|
+
new_mtimes: dict[str, float],
|
|
211
|
+
) -> dict[str, list[str]]:
|
|
212
|
+
"""Parse Go files into adjacency list entries."""
|
|
213
|
+
graph: dict[str, list[str]] = {}
|
|
214
|
+
if regex_mod is None:
|
|
215
|
+
return graph
|
|
216
|
+
|
|
217
|
+
parse_fn = getattr(regex_mod, "parse_go_imports", None)
|
|
218
|
+
if parse_fn is None:
|
|
219
|
+
return graph
|
|
220
|
+
|
|
221
|
+
for go_file in files:
|
|
222
|
+
module_name = _module_name_for_path(root, go_file)
|
|
223
|
+
rel_key = str(go_file.relative_to(root))
|
|
224
|
+
current_mtime = go_file.stat().st_mtime
|
|
225
|
+
|
|
226
|
+
if (
|
|
227
|
+
rel_key in cached_mtimes
|
|
228
|
+
and cached_mtimes[rel_key] == current_mtime
|
|
229
|
+
and module_name in cached_graph
|
|
230
|
+
):
|
|
231
|
+
graph[module_name] = cached_graph[module_name]
|
|
232
|
+
else:
|
|
233
|
+
try:
|
|
234
|
+
result = parse_fn(str(go_file))
|
|
235
|
+
graph[module_name] = _dedupe_preserve_order(result.get("imports", []))
|
|
236
|
+
except Exception:
|
|
237
|
+
graph[module_name] = []
|
|
238
|
+
|
|
239
|
+
new_mtimes[rel_key] = current_mtime
|
|
240
|
+
|
|
241
|
+
return graph
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Cycle detection (DFS-based)
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
def _detect_cycles(graph: dict[str, list[str]]) -> list[list[str]]:
|
|
249
|
+
"""Detect circular dependency cycles via DFS."""
|
|
250
|
+
visited: set[str] = set()
|
|
251
|
+
on_stack: set[str] = set()
|
|
252
|
+
stack: list[str] = []
|
|
253
|
+
cycle_set: set[tuple[str, ...]] = set()
|
|
254
|
+
|
|
255
|
+
def dfs(node: str) -> None:
|
|
256
|
+
visited.add(node)
|
|
257
|
+
on_stack.add(node)
|
|
258
|
+
stack.append(node)
|
|
259
|
+
|
|
260
|
+
for nxt in graph.get(node, []):
|
|
261
|
+
if nxt not in graph:
|
|
262
|
+
continue
|
|
263
|
+
if nxt not in visited:
|
|
264
|
+
dfs(nxt)
|
|
265
|
+
elif nxt in on_stack:
|
|
266
|
+
start_idx = stack.index(nxt)
|
|
267
|
+
cycle = stack[start_idx:] + [nxt]
|
|
268
|
+
cycle_set.add(_canonical_cycle(cycle))
|
|
269
|
+
|
|
270
|
+
stack.pop()
|
|
271
|
+
on_stack.remove(node)
|
|
272
|
+
|
|
273
|
+
for module in graph:
|
|
274
|
+
if module not in visited:
|
|
275
|
+
dfs(module)
|
|
276
|
+
|
|
277
|
+
return [list(c) for c in sorted(cycle_set)]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _canonical_cycle(cycle: list[str]) -> tuple[str, ...]:
|
|
281
|
+
"""Normalize a cycle to a canonical rotation for deduplication."""
|
|
282
|
+
core = cycle[:-1]
|
|
283
|
+
if not core:
|
|
284
|
+
return tuple(cycle)
|
|
285
|
+
rotations = [tuple(core[i:] + core[:i]) for i in range(len(core))]
|
|
286
|
+
minimal = min(rotations)
|
|
287
|
+
return minimal + (minimal[0],)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
# Max depth computation
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def _compute_max_depth(graph: dict[str, list[str]]) -> int:
|
|
295
|
+
"""Compute the longest dependency chain depth."""
|
|
296
|
+
def depth(node: str, path: set[str]) -> int:
|
|
297
|
+
best = 0
|
|
298
|
+
for nxt in graph.get(node, []):
|
|
299
|
+
if nxt not in graph or nxt in path:
|
|
300
|
+
continue
|
|
301
|
+
best = max(best, 1 + depth(nxt, path | {nxt}))
|
|
302
|
+
return best
|
|
303
|
+
|
|
304
|
+
best_depth = 0
|
|
305
|
+
for module in graph:
|
|
306
|
+
best_depth = max(best_depth, depth(module, {module}))
|
|
307
|
+
return best_depth
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Metrics computation
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def _compute_metrics(graph: dict[str, list[str]], cycles: list[list[str]]) -> dict[str, object]:
|
|
315
|
+
"""Compute graph metrics."""
|
|
316
|
+
module_count = len(graph)
|
|
317
|
+
edge_count = sum(len(deps) for deps in graph.values())
|
|
318
|
+
max_depth = _compute_max_depth(graph)
|
|
319
|
+
coupling_score = edge_count / module_count if module_count > 0 else 0.0
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
"module_count": module_count,
|
|
323
|
+
"edge_count": edge_count,
|
|
324
|
+
"max_depth": max_depth,
|
|
325
|
+
"circular_deps": cycles,
|
|
326
|
+
"coupling_score": coupling_score,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# Persistence
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
def _load_cached_state(project_dir: Path) -> tuple[dict[str, list[str]], dict[str, float]]:
|
|
335
|
+
"""Load previously persisted graph and mtime cache."""
|
|
336
|
+
state_path = project_dir / ".omg" / "state" / "dependency-graph.json"
|
|
337
|
+
if not state_path.exists():
|
|
338
|
+
return {}, {}
|
|
339
|
+
try:
|
|
340
|
+
data = json.loads(state_path.read_text(encoding="utf-8"))
|
|
341
|
+
graph = data.get("graph", {})
|
|
342
|
+
mtimes = data.get("_mtime_cache", {})
|
|
343
|
+
return graph, mtimes
|
|
344
|
+
except Exception:
|
|
345
|
+
return {}, {}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _persist(project_dir: Path, payload: dict[str, object]) -> None:
|
|
349
|
+
"""Save full graph + metrics to .omg/state/dependency-graph.json."""
|
|
350
|
+
try:
|
|
351
|
+
output_path = project_dir / ".omg" / "state" / "dependency-graph.json"
|
|
352
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
353
|
+
output_path.write_text(
|
|
354
|
+
json.dumps(payload, indent=2, sort_keys=True),
|
|
355
|
+
encoding="utf-8",
|
|
356
|
+
)
|
|
357
|
+
except Exception:
|
|
358
|
+
pass # Crash isolation: never raise to caller
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
# Public API
|
|
363
|
+
# ---------------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
_EMPTY_RESULT: dict[str, object] = {
|
|
366
|
+
"graph": {},
|
|
367
|
+
"metrics": {
|
|
368
|
+
"module_count": 0,
|
|
369
|
+
"edge_count": 0,
|
|
370
|
+
"max_depth": 0,
|
|
371
|
+
"circular_deps": [],
|
|
372
|
+
"coupling_score": 0.0,
|
|
373
|
+
},
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def build_project_graph(project_dir: str) -> dict[str, object]:
|
|
378
|
+
"""Build unified dependency graph across all detected languages.
|
|
379
|
+
|
|
380
|
+
Combines Python AST parser and JS/TS/Go regex parsers into a single
|
|
381
|
+
adjacency-list graph with computed metrics.
|
|
382
|
+
|
|
383
|
+
Feature-gated behind ``CODEBASE_VIZ`` (default: disabled).
|
|
384
|
+
Supports incremental updates via file mtime tracking.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
dict with keys ``graph`` (adjacency list) and ``metrics``.
|
|
388
|
+
"""
|
|
389
|
+
try:
|
|
390
|
+
# Feature flag gate
|
|
391
|
+
if not _get_feature_flag("CODEBASE_VIZ", default=False):
|
|
392
|
+
return dict(_EMPTY_RESULT)
|
|
393
|
+
|
|
394
|
+
root = Path(project_dir)
|
|
395
|
+
if not root.is_dir():
|
|
396
|
+
return dict(_EMPTY_RESULT)
|
|
397
|
+
|
|
398
|
+
# Load cached state for incremental updates
|
|
399
|
+
cached_graph, cached_mtimes = _load_cached_state(root)
|
|
400
|
+
new_mtimes: dict[str, float] = {}
|
|
401
|
+
|
|
402
|
+
# Import parsers
|
|
403
|
+
ast_mod, regex_mod = _import_parsers()
|
|
404
|
+
|
|
405
|
+
# Collect files by language
|
|
406
|
+
files_by_lang = _collect_files(root)
|
|
407
|
+
|
|
408
|
+
# Parse each language
|
|
409
|
+
unified_graph: dict[str, list[str]] = {}
|
|
410
|
+
|
|
411
|
+
py_graph = _parse_python_files(
|
|
412
|
+
root, files_by_lang["python"], ast_mod, cached_graph, cached_mtimes, new_mtimes
|
|
413
|
+
)
|
|
414
|
+
unified_graph.update(py_graph)
|
|
415
|
+
|
|
416
|
+
js_graph = _parse_js_files(
|
|
417
|
+
root, files_by_lang["js"], regex_mod, cached_graph, cached_mtimes, new_mtimes
|
|
418
|
+
)
|
|
419
|
+
unified_graph.update(js_graph)
|
|
420
|
+
|
|
421
|
+
go_graph = _parse_go_files(
|
|
422
|
+
root, files_by_lang["go"], regex_mod, cached_graph, cached_mtimes, new_mtimes
|
|
423
|
+
)
|
|
424
|
+
unified_graph.update(go_graph)
|
|
425
|
+
|
|
426
|
+
# Compute metrics
|
|
427
|
+
cycles = _detect_cycles(unified_graph)
|
|
428
|
+
metrics = _compute_metrics(unified_graph, cycles)
|
|
429
|
+
|
|
430
|
+
# Build payload
|
|
431
|
+
payload: dict[str, object] = {
|
|
432
|
+
"graph": unified_graph,
|
|
433
|
+
"metrics": metrics,
|
|
434
|
+
"_mtime_cache": new_mtimes,
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
# Persist
|
|
438
|
+
_persist(root, payload)
|
|
439
|
+
|
|
440
|
+
return payload
|
|
441
|
+
|
|
442
|
+
except Exception:
|
|
443
|
+
# Crash isolation: never raise to caller
|
|
444
|
+
return dict(_EMPTY_RESULT)
|