sin-code-bundle 0.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. sin_code_bundle/__init__.py +6 -0
  2. sin_code_bundle/agents_md.py +245 -0
  3. sin_code_bundle/ast_edit.py +323 -0
  4. sin_code_bundle/bench.py +506 -0
  5. sin_code_bundle/budget.py +51 -0
  6. sin_code_bundle/cache.py +131 -0
  7. sin_code_bundle/checkpoint.py +230 -0
  8. sin_code_bundle/cli.py +1943 -0
  9. sin_code_bundle/codocs.py +328 -0
  10. sin_code_bundle/dap_bridge.py +135 -0
  11. sin_code_bundle/data/codocs/SKILL.md +280 -0
  12. sin_code_bundle/gitnexus.py +368 -0
  13. sin_code_bundle/hashline.py +216 -0
  14. sin_code_bundle/hooks.py +249 -0
  15. sin_code_bundle/immortal_commit.py +288 -0
  16. sin_code_bundle/interceptor.py +119 -0
  17. sin_code_bundle/lsp_backend.py +303 -0
  18. sin_code_bundle/lsp_bootstrap.py +85 -0
  19. sin_code_bundle/markitdown.py +254 -0
  20. sin_code_bundle/mcp_config.py +455 -0
  21. sin_code_bundle/mcp_server.py +963 -0
  22. sin_code_bundle/memory.py +208 -0
  23. sin_code_bundle/merge_safety.py +313 -0
  24. sin_code_bundle/orchestration_worktrees.py +102 -0
  25. sin_code_bundle/policy.py +224 -0
  26. sin_code_bundle/preflight.py +152 -0
  27. sin_code_bundle/programming_workflow.py +541 -0
  28. sin_code_bundle/rtk.py +154 -0
  29. sin_code_bundle/safety.py +52 -0
  30. sin_code_bundle/session_warmup.py +247 -0
  31. sin_code_bundle/skills.py +188 -0
  32. sin_code_bundle/symbol_resolve.py +166 -0
  33. sin_code_bundle/tools/__init__.py +4 -0
  34. sin_code_bundle/tools/pypi_setup.py +289 -0
  35. sin_code_bundle/vfs.py +264 -0
  36. sin_code_bundle-0.9.2.dist-info/METADATA +470 -0
  37. sin_code_bundle-0.9.2.dist-info/RECORD +41 -0
  38. sin_code_bundle-0.9.2.dist-info/WHEEL +5 -0
  39. sin_code_bundle-0.9.2.dist-info/entry_points.txt +4 -0
  40. sin_code_bundle-0.9.2.dist-info/licenses/LICENSE +21 -0
  41. sin_code_bundle-0.9.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,52 @@
1
+ """Hardened subprocess + input-sanitization helpers shared by all subsystems."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+ from typing import Optional, Sequence
8
+
9
+ DEFAULT_TIMEOUT = 600 # seconds — never run unbounded
10
+
11
+
12
+ class SafetyError(RuntimeError):
13
+ """Raised when a safety invariant is violated (timeout, unsafe cmd shape, …)."""
14
+
15
+
16
+ def run_checked(
17
+ cmd: Sequence[str],
18
+ cwd: Optional[Path] = None,
19
+ timeout: int = DEFAULT_TIMEOUT,
20
+ allow_shell: bool = False,
21
+ ) -> subprocess.CompletedProcess:
22
+ """Run a subprocess with a mandatory timeout and no shell by default."""
23
+ if not allow_shell and not isinstance(cmd, (list, tuple)):
24
+ raise SafetyError("cmd must be a list/tuple unless allow_shell=True")
25
+ try:
26
+ return subprocess.run(
27
+ cmd,
28
+ cwd=str(cwd) if cwd else None,
29
+ shell=allow_shell,
30
+ timeout=timeout,
31
+ check=False,
32
+ capture_output=True,
33
+ text=True,
34
+ )
35
+ except subprocess.TimeoutExpired as exc:
36
+ raise SafetyError(f"command timed out after {timeout}s: {cmd}") from exc
37
+
38
+
39
+ def sanitize_prompt(
40
+ text: str, max_len: int = 8000
41
+ ) -> str: # 8000 chars ≈ 2K tokens; fits LLM context without flooding
42
+ """Neutralize obvious prompt-injection markers in untrusted task text."""
43
+ if len(text) > max_len:
44
+ text = text[:max_len] + "\n...[truncated]"
45
+ safe_lines = []
46
+ for line in text.splitlines():
47
+ low = line.strip().lower()
48
+ if low.startswith(("system:", "developer:", "ignore previous", "you are now")):
49
+ safe_lines.append("[redacted suspicious instruction]")
50
+ else:
51
+ safe_lines.append(line)
52
+ return "\n".join(safe_lines)
@@ -0,0 +1,247 @@
1
+ """Purpose: One-call session context primer.
2
+
3
+ Docs: session_warmup.doc.md
4
+
5
+ Returns a snapshot of the current repository: branch, git state, CoDocs
6
+ coverage, ceo-audit grade (cached), top risks, and a session-level
7
+ recommendation ("ready to code" vs "fix first"). Designed to be the first
8
+ call an agent makes at the start of a session.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import shutil
15
+ import subprocess
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ # Hard-coded fallback for the dev-machine layout (AGENTS.md).
21
+ _CEO_AUDIT_FALLBACK = "/Users/jeremy/.local/bin/sin"
22
+ _ROLLBACK_FALLBACK = "/Users/jeremy/Library/Python/3.14/bin/sin-honcho-rollback"
23
+
24
+
25
+ def _human_age(seconds: int) -> str:
26
+ """Format a duration in seconds as a short human-readable string.
27
+
28
+ Examples:
29
+ >>> _human_age(45)
30
+ '45s'
31
+ >>> _human_age(3700)
32
+ '1h 1m'
33
+ >>> _human_age(90000)
34
+ '1d 1h'
35
+ """
36
+ if seconds < 0:
37
+ return "0s"
38
+ if seconds < 60:
39
+ return f"{seconds}s"
40
+ minutes, sec = divmod(seconds, 60)
41
+ if minutes < 60:
42
+ return f"{minutes}m {sec}s"
43
+ hours, m = divmod(minutes, 60)
44
+ if hours < 24:
45
+ return f"{hours}h {m}m"
46
+ days, h = divmod(hours, 24)
47
+ return f"{days}d {h}h"
48
+
49
+
50
+ class SessionWarmup:
51
+ """Assemble session context in a single call.
52
+
53
+ Runs 5 independent signals (git state, CoDocs coverage, ceo-audit grade,
54
+ top-risk file scan, last commit time) and returns a structured summary
55
+ with a single ``session_recommendation`` field — so the agent can decide
56
+ "ready" vs "fix first" in one read.
57
+ """
58
+
59
+ def __init__(self, repo_root: Optional[Path] = None) -> None:
60
+ self.repo_root = Path(repo_root) if repo_root else Path.cwd()
61
+
62
+ def warmup(self) -> Dict[str, Any]:
63
+ """Gather all session signals.
64
+
65
+ Returns a dict with the following keys (always present, default
66
+ values on failure): ``branch``, ``git_state``, ``git_changes_count``,
67
+ ``last_commit_age``, ``codocs_coverage``, ``ceo_audit_grade``,
68
+ ``top_risks``, ``session_recommendation``, ``signals``.
69
+ """
70
+ signals: Dict[str, Any] = {}
71
+
72
+ # ── 1. Git state ─────────────────────────────────────────────
73
+ signals["git"] = self._git_state()
74
+
75
+ # ── 2. CoDocs coverage ───────────────────────────────────────
76
+ signals["codocs"] = self._codocs_coverage()
77
+
78
+ # ── 3. ceo-audit (best-effort, no cache here — caller's job) ─
79
+ signals["ceo_audit"] = self._ceo_audit_quick()
80
+
81
+ # ── 4. Top risks (file-level complexity heuristic) ───────────
82
+ signals["top_risks"] = self._top_risks()
83
+
84
+ # ── 5. Last commit time ──────────────────────────────────────
85
+ signals["last_commit"] = self._last_commit_age()
86
+
87
+ # ── Compose final summary ────────────────────────────────────
88
+ branch = signals["git"].get("branch") or "unknown"
89
+ git_clean = signals["git"].get("clean", False)
90
+ docs_ok = signals["codocs"].get("ok", True)
91
+ audit_grade = signals["ceo_audit"].get("grade") or "UNKNOWN"
92
+ top_risks = signals["top_risks"]
93
+
94
+ verdict = self._verdict(
95
+ git_clean=git_clean,
96
+ docs_ok=docs_ok,
97
+ audit_grade=audit_grade,
98
+ top_risks=top_risks,
99
+ )
100
+
101
+ return {
102
+ "branch": branch,
103
+ "git_state": "clean" if git_clean else "dirty",
104
+ "git_changes_count": signals["git"].get("changes_count", 0),
105
+ "last_commit_age": signals["last_commit"].get("age_human"),
106
+ "codocs_coverage": {
107
+ "ok": docs_ok,
108
+ "broken": signals["codocs"].get("broken", 0),
109
+ "checked": signals["codocs"].get("checked", 0),
110
+ },
111
+ "ceo_audit_grade": audit_grade,
112
+ "ceo_audit_path": signals["ceo_audit"].get("report_path"),
113
+ "top_risks": top_risks[:5],
114
+ "session_recommendation": verdict,
115
+ "signals": signals,
116
+ "timestamp": datetime.now(timezone.utc).isoformat(),
117
+ }
118
+
119
+ # ── helpers ─────────────────────────────────────────────────────
120
+ def _git_state(self) -> Dict[str, Any]:
121
+ try:
122
+ branch = (
123
+ subprocess.run(
124
+ ["git", "branch", "--show-current"],
125
+ cwd=self.repo_root,
126
+ capture_output=True,
127
+ text=True,
128
+ timeout=5,
129
+ ).stdout.strip()
130
+ or "(detached)"
131
+ )
132
+ status = subprocess.run(
133
+ ["git", "status", "--porcelain"],
134
+ cwd=self.repo_root,
135
+ capture_output=True,
136
+ text=True,
137
+ timeout=5,
138
+ ).stdout.strip()
139
+ changes = status.splitlines() if status else []
140
+ return {
141
+ "ok": True,
142
+ "branch": branch,
143
+ "clean": not bool(changes),
144
+ "changes_count": len(changes),
145
+ }
146
+ except Exception as exc:
147
+ return {"ok": False, "error": str(exc), "branch": None, "clean": False}
148
+
149
+ def _codocs_coverage(self) -> Dict[str, Any]:
150
+ try:
151
+ from . import codocs
152
+
153
+ broken = codocs.find_broken(str(self.repo_root))
154
+ return {"ok": not bool(broken), "broken": len(broken), "checked": "auto"}
155
+ except Exception as exc:
156
+ return {"ok": True, "broken": 0, "error": str(exc)}
157
+
158
+ def _ceo_audit_quick(self) -> Dict[str, Any]:
159
+ try:
160
+ sin_bin = shutil.which("sin") or _CEO_AUDIT_FALLBACK
161
+ if not Path(sin_bin).exists():
162
+ return {"ok": False, "grade": None, "error": "sin CLI not installed"}
163
+ proc = subprocess.run(
164
+ [sin_bin, "ceo-audit", "run", str(self.repo_root), "--profile=QUICK", "--json"],
165
+ capture_output=True,
166
+ text=True,
167
+ timeout=180, # 3min ceiling; QUICK profile is normally < 1min
168
+ )
169
+ if proc.returncode == 0 and proc.stdout.strip():
170
+ data = json.loads(proc.stdout)
171
+ return {
172
+ "ok": True,
173
+ "grade": data.get("grade"),
174
+ "report_path": data.get("report_path"),
175
+ }
176
+ return {"ok": False, "grade": None, "error": proc.stderr[-300:]}
177
+ except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
178
+ return {"ok": False, "grade": None, "error": str(exc)}
179
+ except Exception as exc:
180
+ return {"ok": False, "grade": None, "error": str(exc)}
181
+
182
+ def _top_risks(self) -> List[Dict[str, Any]]:
183
+ """Heuristic: largest 5 Python files by line count are 'top risks'.
184
+
185
+ Cheap proxy for "where could go wrong". Returns up to 5 entries with
186
+ ``path`` and ``lines``. No external tool required.
187
+ """
188
+ try:
189
+ files: List[Dict[str, Any]] = []
190
+ for p in self.repo_root.rglob("*.py"):
191
+ rel = p.relative_to(self.repo_root)
192
+ if any(
193
+ part.startswith(".") or part in {"__pycache__", "node_modules", "venv", ".venv"}
194
+ for part in rel.parts
195
+ ):
196
+ continue
197
+ try:
198
+ n = sum(1 for _ in p.open("r", encoding="utf-8", errors="ignore"))
199
+ except Exception:
200
+ continue
201
+ if n > 200: # Only flag meaningfully large files
202
+ files.append({"path": str(rel), "lines": n})
203
+ files.sort(key=lambda x: x["lines"], reverse=True)
204
+ return files[:5]
205
+ except Exception:
206
+ return []
207
+
208
+ def _last_commit_age(self) -> Dict[str, Any]:
209
+ try:
210
+ proc = subprocess.run(
211
+ ["git", "log", "-1", "--format=%ct"],
212
+ cwd=self.repo_root,
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=5,
216
+ )
217
+ if proc.returncode == 0 and proc.stdout.strip():
218
+ ts = int(proc.stdout.strip())
219
+ age_sec = int(datetime.now(timezone.utc).timestamp()) - ts
220
+ return {"ok": True, "age_sec": age_sec, "age_human": _human_age(age_sec)}
221
+ return {"ok": False, "age_human": "unknown"}
222
+ except Exception as exc:
223
+ return {"ok": False, "error": str(exc), "age_human": "unknown"}
224
+
225
+ def _verdict(
226
+ self,
227
+ git_clean: bool,
228
+ docs_ok: bool,
229
+ audit_grade: str,
230
+ top_risks: List[Dict[str, Any]],
231
+ ) -> str:
232
+ """Single-line recommendation for the agent.
233
+
234
+ Logic, in order:
235
+ 1. ceo-audit F → "BLOCK — fix critical issues first"
236
+ 2. ceo-audit D or many broken docs → "FIX — improve before coding"
237
+ 3. Dirty tree with > 20 changes → "STASH or COMMIT first"
238
+ 4. Everything else → "READY — proceed with coding"
239
+ """
240
+ grade = (audit_grade or "UNKNOWN").upper()
241
+ if grade == "F":
242
+ return "BLOCK — ceo-audit grade F. Fix critical issues first."
243
+ if grade == "D" or (not docs_ok and len(top_risks) > 0):
244
+ return "FIX — improve docs/quality before coding"
245
+ if not git_clean:
246
+ return "STASH or COMMIT first — working tree dirty"
247
+ return "READY — proceed with coding"
@@ -0,0 +1,188 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Compile portable SIN skills into each agent's native command/skill format.
3
+
4
+ One source of truth: `skills/*.md` with YAML frontmatter (name, description,
5
+ arguments) + a prompt body. `compile_skills()` renders them into:
6
+
7
+ - opencode -> .opencode/command/<name>.md (frontmatter: description, agent)
8
+ - codex -> ~/.codex/prompts/<name>.md (plain prompt, $N positional args)
9
+ - claude -> .claude/skills/<name>/SKILL.md (frontmatter: name, description)
10
+
11
+ This mirrors how cross-agent tools (Ulis/Nexel) keep a single prompt library in
12
+ sync across CLIs.
13
+
14
+ Docs: skills.doc.md
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Literal
23
+
24
+ try:
25
+ import yaml
26
+ except ImportError: # pragma: no cover
27
+ # pyyaml is optional at import time so that ``import sin_code_bundle.skills``
28
+ # never fails. Parsing functions raise a clear error instead. This lets the
29
+ # module be loaded in slim CI environments that don't need YAML support.
30
+ yaml = None # type: ignore
31
+
32
+ # ── Targets & Schemas ────────────────────────────────────────────────
33
+
34
+ # Supported target agents. Adding a new target requires:
35
+ # 1. extend the ``Target`` literal
36
+ # 2. add a branch in :func:`render_skill`
37
+ # 3. decide where it writes (per-user, per-repo, etc.)
38
+ Target = Literal["opencode", "codex", "claude"]
39
+ SUPPORTED_TARGETS: tuple[Target, ...] = ("opencode", "codex", "claude")
40
+
41
+ # Matches the standard YAML frontmatter used by our skills/*.md sources.
42
+ # Captures (yaml_meta, body). The trailing ``.*$`` with DOTALL lets the body
43
+ # span newlines so multi-paragraph prompts round-trip cleanly.
44
+ _FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
45
+
46
+
47
+ # ── Skill Model ──────────────────────────────────────────────────────
48
+
49
+
50
+ @dataclass
51
+ class Skill:
52
+ """A parsed source skill (one ``skills/*.md`` file).
53
+
54
+ Attributes:
55
+ name: Stable identifier — used as the filename in every target's
56
+ output. Falls back to the source filename stem if the frontmatter
57
+ omits it.
58
+ description: One-line summary shown in command pickers.
59
+ body: Prompt body, with YAML frontmatter stripped.
60
+ arguments: List of argument descriptors (each a dict with at least a
61
+ ``"name"`` key). Used to template ``{{name}}`` placeholders when
62
+ rendering to codex (which expects positional ``$N``).
63
+ """
64
+
65
+ name: str
66
+ description: str
67
+ body: str
68
+ arguments: list[dict] = field(default_factory=list)
69
+
70
+ @classmethod
71
+ def parse(cls, path: Path) -> "Skill":
72
+ """Parse a single ``skills/*.md`` file into a :class:`Skill`.
73
+
74
+ Args:
75
+ path: Filesystem path to a markdown file with YAML frontmatter
76
+ delimited by ``---`` fences.
77
+
78
+ Returns:
79
+ The parsed :class:`Skill`.
80
+
81
+ Raises:
82
+ ValueError: If the file is missing the YAML frontmatter block.
83
+ RuntimeError: If PyYAML is not installed in the environment.
84
+ """
85
+ text = path.read_text(encoding="utf-8")
86
+ m = _FRONTMATTER_RE.match(text)
87
+ if not m:
88
+ raise ValueError(f"{path} is missing YAML frontmatter")
89
+ if yaml is None:
90
+ raise RuntimeError("pyyaml is required to parse skills")
91
+ meta = yaml.safe_load(m.group(1)) or {}
92
+ return cls(
93
+ name=meta.get("name", path.stem),
94
+ description=meta.get("description", ""),
95
+ body=m.group(2).strip(),
96
+ arguments=meta.get("arguments", []) or [],
97
+ )
98
+
99
+
100
+ # ── Rendering ────────────────────────────────────────────────────────
101
+
102
+
103
+ def _body_for_codex(skill: Skill) -> str:
104
+ """Codex prompts use positional $1, $2 ... — map {{arg}} -> $N."""
105
+ body = skill.body
106
+ for i, arg in enumerate(skill.arguments, start=1):
107
+ body = body.replace("{{" + arg["name"] + "}}", f"${i}")
108
+ return body
109
+
110
+
111
+ def render_skill(skill: Skill, target: Target) -> tuple[str, str]:
112
+ """Return (relative_output_path, file_content) for a target agent.
113
+
114
+ The relative output path is interpreted relative to the base directory
115
+ chosen by :func:`compile_skills` (per-user for codex, per-repo for the
116
+ others). The path includes a trailing filename and parent directory so
117
+ callers only have to ``open`` it for writing.
118
+ """
119
+ if target == "opencode":
120
+ # ``agent: build`` is the default opencode sub-agent that runs custom
121
+ # commands; we hard-code it here because all our skills target it.
122
+ fm = f"---\ndescription: {skill.description}\nagent: build\n---\n\n"
123
+ return f".opencode/command/{skill.name}.md", fm + skill.body + "\n"
124
+
125
+ if target == "codex":
126
+ # codex uses ``prompts/`` (not dot-prefixed) and a flat layout under
127
+ # ~/.codex — no frontmatter, just the templated body.
128
+ return f"prompts/{skill.name}.md", _body_for_codex(skill) + "\n"
129
+
130
+ if target == "claude":
131
+ # claude skills live in a per-skill subdirectory with a fixed
132
+ # ``SKILL.md`` name — that's the convention their loader expects.
133
+ fm = f"---\nname: {skill.name}\ndescription: {skill.description}\n---\n\n"
134
+ return f".claude/skills/{skill.name}/SKILL.md", fm + skill.body + "\n"
135
+
136
+ raise ValueError(f"unknown target: {target}")
137
+
138
+
139
+ # ── Discovery & Compilation ──────────────────────────────────────────
140
+
141
+
142
+ def load_skills(source_dir: Path = Path("skills")) -> list[Skill]:
143
+ """Load every ``*.md`` file under ``source_dir`` as a :class:`Skill`.
144
+
145
+ Returns an empty list if ``source_dir`` does not exist (e.g. running
146
+ in a clone that hasn't checked out the skills library). The result is
147
+ sorted by filename for deterministic, diff-friendly output ordering.
148
+ """
149
+ if not source_dir.exists():
150
+ return []
151
+ return [Skill.parse(p) for p in sorted(source_dir.glob("*.md"))]
152
+
153
+
154
+ def compile_skills(
155
+ target: Target,
156
+ source_dir: Path = Path("skills"),
157
+ out_root: Path = Path("."),
158
+ dry_run: bool = False,
159
+ ) -> list[Path]:
160
+ """Compile every source skill into `target`'s native format.
161
+
162
+ For codex, paths are written under the user's ~/.codex/; for opencode and
163
+ claude they are written relative to the repo (out_root).
164
+
165
+ Args:
166
+ target: Which agent to render for.
167
+ source_dir: Directory containing ``*.md`` source skills.
168
+ out_root: Base directory for opencode/claude outputs (ignored for
169
+ codex, which always lands under ``~/.codex``).
170
+ dry_run: If True, return the would-be destination paths without
171
+ writing files or creating parent directories.
172
+
173
+ Returns:
174
+ A list of destination paths in the same order as the source skills.
175
+ """
176
+ written: list[Path] = []
177
+ # codex installs prompts per-user (other agents are per-repo), so its
178
+ # base dir is fixed to ~/.codex regardless of out_root.
179
+ base = Path.home() / ".codex" if target == "codex" else out_root
180
+
181
+ for skill in load_skills(source_dir):
182
+ rel, content = render_skill(skill, target)
183
+ dest = base / rel
184
+ written.append(dest)
185
+ if not dry_run:
186
+ dest.parent.mkdir(parents=True, exist_ok=True)
187
+ dest.write_text(content, encoding="utf-8")
188
+ return written
@@ -0,0 +1,166 @@
1
+ # Purpose: Unified code archaeology — graph + cross-source context in 1 call.
2
+ # Docs: symbol_resolve.doc.md
3
+ """Consolidates gitnexus_query + gitnexus_context + gitnexus_impact +
4
+ gitnexus_detect_changes. Also integrates sin-context-bridge for cross-source
5
+ context (memory, code knowledge graph).
6
+
7
+ Docs: symbol_resolve.doc.md
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import shutil
14
+ import subprocess
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ # Default binary names we look up on PATH. Hard-coded fallback paths exist
19
+ # for the dev machine layout documented in AGENTS.md so the tool keeps
20
+ # working when PATH is restricted (e.g. inside the MCP stdio process).
21
+ _GITNEXUS_FALLBACK = "/Users/jeremy/Library/Python/3.14/bin/gitnexus"
22
+ _CONTEXT_BRIDGE_FALLBACK = "/Users/jeremy/Library/Python/3.14/bin/sin-context-bridge"
23
+
24
+
25
+ class SymbolResolver:
26
+ """One-call code archaeology for any symbol.
27
+
28
+ Fans out to gitnexus primitives + sin-context-bridge. Each source
29
+ degrades independently — missing CLI or failing command leaves the
30
+ result empty but does not raise.
31
+ """
32
+
33
+ def __init__(self, repo_root: Optional[Path] = None) -> None:
34
+ self.repo_root = Path(repo_root) if repo_root else Path.cwd()
35
+
36
+ def resolve(
37
+ self,
38
+ name: str,
39
+ depth: int = 2,
40
+ include: Optional[List[str]] = None,
41
+ ) -> Dict[str, Any]:
42
+ """Resolve a symbol via multiple graph queries.
43
+
44
+ Args:
45
+ name: function, class, or module name.
46
+ depth: 1-3 levels of graph traversal.
47
+ include: subset of {callers, callees, blast, recent, cross}.
48
+ Defaults to all except ``cross``.
49
+
50
+ Returns:
51
+ Dict with per-source slices and a ``sources_queried`` list
52
+ showing which CLIs responded successfully.
53
+ """
54
+ if include is None:
55
+ include = ["callers", "callees", "blast", "recent"]
56
+
57
+ # ── Resolve binaries ────────────────────────────────────────────
58
+ # shutil.which is the canonical way to find a CLI on PATH; the
59
+ # hard-coded fallbacks cover the dev-machine layout from AGENTS.md.
60
+ gitnexus_bin = shutil.which("gitnexus") or _GITNEXUS_FALLBACK
61
+ context_bridge_bin = shutil.which("sin-context-bridge") or _CONTEXT_BRIDGE_FALLBACK
62
+
63
+ result: Dict[str, Any] = {
64
+ "symbol": name,
65
+ "depth": depth,
66
+ "include": include,
67
+ "callers": [],
68
+ "callees": [],
69
+ "blast_radius": {},
70
+ "recent_changes": [],
71
+ "cross_source": {},
72
+ "sources_queried": [],
73
+ }
74
+
75
+ # ── 1. Callers + callees (gitnexus context) ─────────────────────
76
+ # One CLI call feeds both slices — same JSON payload.
77
+ if ("callers" in include or "callees" in include) and Path(gitnexus_bin).exists():
78
+ try:
79
+ proc = subprocess.run(
80
+ [gitnexus_bin, "context", name, "--json"],
81
+ capture_output=True,
82
+ text=True,
83
+ timeout=10,
84
+ )
85
+ if proc.returncode == 0 and proc.stdout.strip():
86
+ data = json.loads(proc.stdout)
87
+ if "callers" in include:
88
+ result["callers"] = data.get("callers", [])
89
+ if "callees" in include:
90
+ result["callees"] = data.get("callees", [])
91
+ result["sources_queried"].append("gitnexus:context")
92
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
93
+ # Graceful degradation — leave the slice empty.
94
+ pass
95
+
96
+ # ── 2. Blast radius (gitnexus impact) ───────────────────────────
97
+ # Groups affected symbols by traversal depth for risk assessment.
98
+ if "blast" in include and Path(gitnexus_bin).exists():
99
+ try:
100
+ proc = subprocess.run(
101
+ [gitnexus_bin, "impact", json.dumps({"target": name})],
102
+ capture_output=True,
103
+ text=True,
104
+ timeout=15,
105
+ )
106
+ if proc.returncode == 0 and proc.stdout.strip():
107
+ data = json.loads(proc.stdout)
108
+ affected = data.get("affected", [])
109
+ for item in affected:
110
+ d = item.get("depth", 1)
111
+ result["blast_radius"].setdefault(f"d{d}", []).append(item)
112
+ result["sources_queried"].append("gitnexus:impact")
113
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
114
+ pass
115
+
116
+ # ── 3. Recent changes (gitnexus detect-changes) ─────────────────
117
+ # Filters to changes mentioning the symbol — useful for "is this
118
+ # currently being modified?" answers.
119
+ if "recent" in include and Path(gitnexus_bin).exists():
120
+ try:
121
+ proc = subprocess.run(
122
+ [gitnexus_bin, "detect-changes", "--json"],
123
+ capture_output=True,
124
+ text=True,
125
+ timeout=10,
126
+ )
127
+ if proc.returncode == 0 and proc.stdout.strip():
128
+ data = json.loads(proc.stdout)
129
+ all_changes = data.get("changes", [])
130
+ # Filter cheaply by checking each change dict's JSON
131
+ # representation; cheaper than traversing every field.
132
+ result["recent_changes"] = [
133
+ c for c in all_changes if name.lower() in json.dumps(c).lower()
134
+ ]
135
+ result["sources_queried"].append("gitnexus:detect-changes")
136
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
137
+ pass
138
+
139
+ # ── 4. Cross-source (sin-context-bridge) ────────────────────────
140
+ # Bridges local SCKG + remote sin-brain + local memory into one view.
141
+ if "cross" in include and Path(context_bridge_bin).exists():
142
+ try:
143
+ proc = subprocess.run(
144
+ [
145
+ context_bridge_bin,
146
+ "query",
147
+ name,
148
+ "--sources",
149
+ "sckg,sin_brain,local",
150
+ ],
151
+ capture_output=True,
152
+ text=True,
153
+ timeout=15,
154
+ )
155
+ if proc.returncode == 0 and proc.stdout.strip():
156
+ data = json.loads(proc.stdout)
157
+ result["cross_source"] = {
158
+ "query": name,
159
+ "chunks": data.get("chunks", []),
160
+ "sources_queried": data.get("sources_queried", []),
161
+ }
162
+ result["sources_queried"].append("sin-context-bridge")
163
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
164
+ pass
165
+
166
+ return result
@@ -0,0 +1,4 @@
1
+ """Maintainer tools bundled with sin-code-bundle.
2
+
3
+ Docs: __init__.doc.md
4
+ """