sidegraph 0.1.0__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.
@@ -0,0 +1,184 @@
1
+ """Pure, local inspection of Sidegraph host integration configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shlex
7
+ import tomllib
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+ from typing import Any, Literal
11
+
12
+ from .model import HostKind, IntegrationResult
13
+
14
+ Status = Literal["verified", "missing", "invalid", "unsupported"]
15
+
16
+
17
+ def _load_json(path: Path) -> tuple[Mapping[str, Any] | None, bool]:
18
+ if not path.is_file():
19
+ return None, False
20
+ try:
21
+ data = json.loads(path.read_text(encoding="utf-8"))
22
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError):
23
+ return None, True
24
+ return (data, False) if isinstance(data, Mapping) else (None, True)
25
+
26
+
27
+ def _load_toml(path: Path) -> tuple[Mapping[str, Any] | None, bool]:
28
+ if not path.is_file():
29
+ return None, False
30
+ try:
31
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
32
+ except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError):
33
+ return None, True
34
+ return data, False
35
+
36
+
37
+ def _command_strings(value: object) -> tuple[str, ...]:
38
+ # Iterative, not recursive: value is untrusted, user-editable JSON/TOML (.mcp.json,
39
+ # Codex hooks config), and a config author can nest it arbitrarily deep. A recursive
40
+ # walk hits CPython's call-stack recursion limit on a few thousand levels of nesting
41
+ # and raises RecursionError with no injection needed at all -- an explicit worklist
42
+ # is bounded only by heap memory, not the call stack, so no plausible config depth
43
+ # can trip it.
44
+ commands: list[str] = []
45
+ stack: list[object] = [value]
46
+ while stack:
47
+ current = stack.pop()
48
+ if isinstance(current, Mapping):
49
+ for key, child in current.items():
50
+ if key == "command" and isinstance(child, str):
51
+ commands.append(child)
52
+ stack.append(child)
53
+ elif isinstance(current, list):
54
+ stack.extend(current)
55
+ return tuple(commands)
56
+
57
+
58
+ def _contains_entrypoint(commands: tuple[str, ...], entrypoint: str) -> bool:
59
+ for command in commands:
60
+ try:
61
+ if entrypoint in shlex.split(command):
62
+ return True
63
+ except ValueError:
64
+ continue
65
+ return False
66
+
67
+
68
+ def _status(config: Mapping[str, Any] | None, invalid: bool, entrypoint: str) -> Status:
69
+ if invalid:
70
+ return "invalid"
71
+ if config is None:
72
+ return "missing"
73
+ return "verified" if _contains_entrypoint(_command_strings(config), entrypoint) else "missing"
74
+
75
+
76
+ def _event_status(
77
+ config: Mapping[str, Any] | None,
78
+ invalid: bool,
79
+ event: str,
80
+ entrypoint: str,
81
+ ) -> Status:
82
+ if invalid:
83
+ return "invalid"
84
+ if config is None:
85
+ return "missing"
86
+ hooks = config.get("hooks")
87
+ if not isinstance(hooks, Mapping):
88
+ return "missing"
89
+ event_config = hooks.get(event)
90
+ return (
91
+ "verified"
92
+ if _contains_entrypoint(_command_strings(event_config), entrypoint)
93
+ else "missing"
94
+ )
95
+
96
+
97
+ def _repair_action(path: Path, root: Path) -> str:
98
+ try:
99
+ display_path = path.relative_to(root).as_posix()
100
+ except ValueError:
101
+ display_path = path.name
102
+ return f"Repair {display_path}, then rerun sidegraph-bootstrap"
103
+
104
+
105
+ def _configure_action(entrypoint: str, path: Path, root: Path) -> str:
106
+ try:
107
+ display_path = path.relative_to(root).as_posix()
108
+ except ValueError:
109
+ display_path = path.name
110
+ return f"Configure {entrypoint} in {display_path}, then rerun sidegraph-bootstrap"
111
+
112
+
113
+ def _next_action(checks: tuple[tuple[Status, str, Path, bool], ...], root: Path) -> str | None:
114
+ for status, entrypoint, path, invalid in checks:
115
+ if invalid:
116
+ return _repair_action(path, root)
117
+ if status != "verified":
118
+ return _configure_action(entrypoint, path, root)
119
+ return None
120
+
121
+
122
+ def _verify_claude(root: Path) -> IntegrationResult:
123
+ mcp_path = root / ".mcp.json"
124
+ hooks_path = root / ".claude" / "settings.json"
125
+ mcp_config, mcp_invalid = _load_json(mcp_path)
126
+ hooks_config, hooks_invalid = _load_json(hooks_path)
127
+ mcp = _status(mcp_config, mcp_invalid, "sidegraph-mcp")
128
+ session_start = _event_status(
129
+ hooks_config, hooks_invalid, "SessionStart", "sidegraph-session-start"
130
+ )
131
+ stop = _event_status(hooks_config, hooks_invalid, "Stop", "sidegraph-stop")
132
+ pretool = _event_status(hooks_config, hooks_invalid, "PreToolUse", "sidegraph-pre-tool-use")
133
+ checks = (
134
+ (mcp, "sidegraph-mcp", mcp_path, mcp_invalid),
135
+ (session_start, "sidegraph-session-start", hooks_path, hooks_invalid),
136
+ (stop, "sidegraph-stop", hooks_path, hooks_invalid),
137
+ (pretool, "sidegraph-pre-tool-use", hooks_path, hooks_invalid),
138
+ )
139
+ fully_supported = all(status == "verified" for status, *_ in checks)
140
+ return IntegrationResult(
141
+ host=HostKind.CLAUDE_CODE,
142
+ mcp=mcp,
143
+ session_start=session_start,
144
+ stop=stop,
145
+ pretool_read_grep=pretool,
146
+ fully_supported=fully_supported,
147
+ next_action=None if fully_supported else _next_action(checks, root),
148
+ )
149
+
150
+
151
+ def _verify_codex(root: Path, codex_config: Path | None) -> IntegrationResult:
152
+ mcp_path = codex_config or root / ".codex" / "config.toml"
153
+ hooks_path = root / ".codex" / "hooks" / "hooks.json"
154
+ mcp_config, mcp_invalid = _load_toml(mcp_path)
155
+ hooks_config, hooks_invalid = _load_json(hooks_path)
156
+ mcp_status = _status(mcp_config, mcp_invalid, "sidegraph-mcp")
157
+ session_status = _event_status(
158
+ hooks_config, hooks_invalid, "SessionStart", "sidegraph-session-start"
159
+ )
160
+ stop_status = _event_status(hooks_config, hooks_invalid, "Stop", "sidegraph-stop")
161
+ checks = (
162
+ (mcp_status, "sidegraph-mcp", mcp_path, mcp_invalid),
163
+ (session_status, "sidegraph-session-start", hooks_path, hooks_invalid),
164
+ (stop_status, "sidegraph-stop", hooks_path, hooks_invalid),
165
+ )
166
+ next_action = _next_action(checks, root)
167
+ return IntegrationResult(
168
+ host=HostKind.CODEX,
169
+ mcp=mcp_status,
170
+ session_start=session_status,
171
+ stop=stop_status,
172
+ pretool_read_grep="unsupported",
173
+ fully_supported=False,
174
+ next_action=next_action,
175
+ )
176
+
177
+
178
+ def verify_integration(
179
+ root: Path, host: HostKind, *, codex_config: Path | None = None
180
+ ) -> IntegrationResult:
181
+ """Inspect host configuration without executing a host or modifying its files."""
182
+ if host == HostKind.CLAUDE_CODE:
183
+ return _verify_claude(root)
184
+ return _verify_codex(root, codex_config)
@@ -0,0 +1,277 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from collections.abc import Mapping
5
+ from enum import StrEnum
6
+ from typing import Any, Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, computed_field
9
+
10
+ from sidegraph.profiles import ProfileDetection as ProfileDetection
11
+ from sidegraph.schema import Decision, DecisionKind, DecisionStatus, Descriptor
12
+
13
+
14
+ class FrozenModel(BaseModel):
15
+ model_config = ConfigDict(frozen=True)
16
+
17
+
18
+ class WarningCode(StrEnum):
19
+ MISSING_CHOICE = "missing-choice"
20
+ MISSING_REJECTED = "missing-rejected-alternatives"
21
+ UNRESOLVED_ANCHOR = "unresolved-anchor"
22
+ AMBIGUOUS_ANCHOR = "ambiguous-anchor"
23
+ CURRENT_STATE = "likely-current-state-summary"
24
+ DUPLICATE_PLAN = "duplicate-within-plan"
25
+ DUPLICATE_CANONICAL = "duplicate-canonical-memory"
26
+
27
+
28
+ # One consequence sentence per WarningCode (design §3.8, m3) — rendered beside the bare code
29
+ # in render_candidate so a reviewer sees what accepting will actually do, not just a label.
30
+ # A stable, documented public contract (predecessor spec §3.3): pinned by
31
+ # tests/test_bootstrap_review.py and mirrored in docs/getting-started/bootstrap.md.
32
+ #
33
+ # DUPLICATE_CANONICAL is the one load-bearing case: it fires on a REF match
34
+ # (planner.py's _enrich_candidate queries catalog.find_by_ref), never a content match, so
35
+ # its sentence must stay true across every content relationship that ref can carry —
36
+ # checked clause by clause, by construction, against all six write-path cells
37
+ # (existing accepted/proposed/rejected record x identical/changed accepted content; see
38
+ # design §3.8's table and the doc-import write seam in doc_import.py/planner.py):
39
+ # accepted + identical -> skipped-existing, record unchanged (bindings may still repair)
40
+ # accepted + changed -> superseding successor written, predecessor closed
41
+ # proposed + identical -> ratified in place (the accept grant from spec §3.1), any
42
+ # supporting facts cascade
43
+ # proposed + changed -> Store.drop marks the stale draft rejected, a fresh record is
44
+ # written with supersedes=None
45
+ # rejected + identical -> needs_status_change flips; a new accepted record revives it
46
+ # with supersedes=<rejected id>
47
+ # rejected + changed -> a fresh accepted record is written with supersedes=None (no
48
+ # open record exists to close, so the clause is vacuously true)
49
+ # Two earlier drafts of this sentence were false: one claimed accepting always "writes a
50
+ # successor that supersedes the stored record" (false for identical-vs-accepted); the next
51
+ # claimed identical content "is skipped without a write" (false for the ratify and revive
52
+ # cells, and over-claimed even for the one cell it fit, since the skipped-existing branch
53
+ # still runs _apply_doc_bindings and can write binding records).
54
+ WARNING_CONSEQUENCES: Mapping[WarningCode, str] = {
55
+ WarningCode.MISSING_CHOICE: (
56
+ "the document states no decision; the record would carry only context"
57
+ ),
58
+ WarningCode.MISSING_REJECTED: (
59
+ "no rejected alternatives were found; the most valuable field stays empty"
60
+ ),
61
+ WarningCode.UNRESOLVED_ANCHOR: (
62
+ "the anchor does not resolve; the record would not surface for that code"
63
+ ),
64
+ WarningCode.AMBIGUOUS_ANCHOR: (
65
+ "several entities match; Sidegraph never guesses, so the anchor stays degraded"
66
+ ),
67
+ WarningCode.CURRENT_STATE: (
68
+ "this reads as a current-state summary, not a decision with a fork"
69
+ ),
70
+ WarningCode.DUPLICATE_PLAN: ("another candidate in this same plan carries identical content"),
71
+ WarningCode.DUPLICATE_CANONICAL: (
72
+ "a record already exists for this source; accepting identical content leaves an "
73
+ "accepted record unchanged, ratifies a pending proposal, or revives a rejected "
74
+ "record — accepting changed content writes a replacement (closing any open record "
75
+ "at this source), and edits made in an earlier review are not carried over"
76
+ ),
77
+ }
78
+
79
+
80
+ class Exclusion(FrozenModel):
81
+ path: str
82
+ reason: str
83
+
84
+
85
+ class ScanResult(FrozenModel):
86
+ root: str
87
+ files: tuple[str, ...] = ()
88
+ exclusions: tuple[Exclusion, ...] = ()
89
+ max_bytes: int = 512_000
90
+
91
+
92
+ class SourceFingerprint(FrozenModel):
93
+ path: str
94
+ sha256: str
95
+
96
+
97
+ class AnchorPlan(FrozenModel):
98
+ descriptor: Descriptor
99
+ status: Literal["resolved", "ambiguous", "unresolved"]
100
+ candidates: tuple[str, ...] = ()
101
+ tier: int | None = None
102
+
103
+
104
+ class PlanIssue(FrozenModel):
105
+ file_path: str
106
+ ref: str | None = None
107
+ warning: WarningCode
108
+ detail: str
109
+
110
+
111
+ class EditableCandidate(FrozenModel):
112
+ title: str
113
+ context: str
114
+ choice: str
115
+ rejected: str | None = None
116
+ consequences: str | None = None
117
+ kind: DecisionKind
118
+
119
+
120
+ class BootstrapCandidate(FrozenModel):
121
+ key: str
122
+ file_path: str
123
+ ref: str
124
+ fragment: str | None = None
125
+ source_hash: str
126
+ title: str
127
+ context: str
128
+ choice: str
129
+ rejected: str | None = None
130
+ consequences: str | None = None
131
+ kind: DecisionKind
132
+ default_status: DecisionStatus
133
+ redacted_anchor_text: str = Field(exclude=True)
134
+ anchor_intents: tuple[Descriptor, ...] = ()
135
+ file_anchor_intent: Descriptor | None = None
136
+ anchors: tuple[AnchorPlan, ...] = ()
137
+ warnings: tuple[WarningCode, ...] = ()
138
+
139
+ @classmethod
140
+ def from_fields(cls, **fields: Any) -> BootstrapCandidate:
141
+ material = "\0".join(
142
+ str(fields.get(name) or "")
143
+ for name in (
144
+ "ref",
145
+ "title",
146
+ "context",
147
+ "choice",
148
+ "rejected",
149
+ "consequences",
150
+ "kind",
151
+ )
152
+ )
153
+ return cls(key=hashlib.sha256(material.encode()).hexdigest()[:16], **fields)
154
+
155
+
156
+ class BootstrapPlan(FrozenModel):
157
+ root: str
158
+ profile: str
159
+ fingerprint: str
160
+ source_fingerprints: tuple[SourceFingerprint, ...] = ()
161
+ catalog_fingerprint: str
162
+ graph_version: str | None = None
163
+ candidates: tuple[BootstrapCandidate, ...] = ()
164
+ issues: tuple[PlanIssue, ...] = ()
165
+ files_read: tuple[str, ...] = ()
166
+ exclusions: tuple[Exclusion, ...] = ()
167
+
168
+
169
+ class ReviewAction(StrEnum):
170
+ ACCEPT = "accept"
171
+ KEEP_PROPOSED = "keep-proposed"
172
+ SKIP = "skip"
173
+
174
+
175
+ class ReviewedCandidate(FrozenModel):
176
+ candidate: BootstrapCandidate
177
+ action: ReviewAction
178
+ edited: bool = False
179
+ action_elapsed_seconds: float = Field(default=0.0, ge=0.0)
180
+
181
+
182
+ class ReviewResult(FrozenModel):
183
+ items: tuple[ReviewedCandidate, ...]
184
+ elapsed_seconds: float = Field(default=0.0, ge=0.0)
185
+
186
+ @computed_field # type: ignore[prop-decorator]
187
+ @property
188
+ def has_writes(self) -> bool:
189
+ return any(item.action != ReviewAction.SKIP for item in self.items)
190
+
191
+
192
+ class EditResult(FrozenModel):
193
+ candidate: BootstrapCandidate
194
+ diff: str
195
+
196
+
197
+ class RunStatus(StrEnum):
198
+ COMPLETE = "complete"
199
+ PARTIAL_RECOVERABLE = "partial-recoverable"
200
+ INCOMPLETE = "incomplete"
201
+ DIAGNOSTIC = "diagnostic"
202
+
203
+
204
+ class AcceptedRecord(FrozenModel):
205
+ record_id: str
206
+ ref: str
207
+
208
+
209
+ class Reconciliation(FrozenModel):
210
+ durable: tuple[str, ...] = ()
211
+ pending: tuple[str, ...] = ()
212
+ canonical_files: tuple[str, ...] = ()
213
+ durable_accepted_records: tuple[AcceptedRecord, ...] = ()
214
+
215
+
216
+ class BootstrapReport(FrozenModel):
217
+ status: RunStatus
218
+ documents_scanned: int
219
+ candidates: int
220
+ accepted: int
221
+ kept_proposed: int
222
+ skipped: int
223
+ reviewed_candidates: int = 0
224
+ accepted_without_edit: int = 0
225
+ edited_then_accepted: int = 0
226
+ kept_proposed_without_edit: int = 0
227
+ edited_then_kept_proposed: int = 0
228
+ candidate_precision_numerator: int = 0
229
+ review_elapsed_seconds: float = 0.0
230
+ accept_action_seconds: float = 0.0
231
+ edit_accept_action_seconds: float = 0.0
232
+ keep_proposed_action_seconds: float = 0.0
233
+ edit_keep_proposed_action_seconds: float = 0.0
234
+ skip_action_seconds: float = 0.0
235
+ live_anchors: int
236
+ review_debt_count: int
237
+ oldest_proposal_days: int | None = None
238
+ durable_candidate_keys: tuple[str, ...] = ()
239
+ pending_candidate_keys: tuple[str, ...] = ()
240
+ durable_accepted_records: tuple[AcceptedRecord, ...] = ()
241
+ canonical_files: tuple[str, ...] = ()
242
+ verification_failures: tuple[str, ...] = ()
243
+ failed_ref: str | None = None
244
+ error: str | None = None
245
+ next_command: str | None = None
246
+
247
+
248
+ class HostKind(StrEnum):
249
+ CLAUDE_CODE = "claude-code"
250
+ CODEX = "codex"
251
+
252
+
253
+ class IntegrationResult(FrozenModel):
254
+ host: HostKind
255
+ mcp: Literal["verified", "missing", "invalid", "unsupported"]
256
+ session_start: Literal["verified", "missing", "invalid", "unsupported"]
257
+ stop: Literal["verified", "missing", "invalid", "unsupported"]
258
+ pretool_read_grep: Literal["verified", "missing", "invalid", "unsupported"]
259
+ fully_supported: bool
260
+ next_action: str | None = None
261
+
262
+
263
+ class ProofSelection(FrozenModel):
264
+ decision: Decision
265
+ file_path: str
266
+ rule: str
267
+
268
+
269
+ class ProofResult(FrozenModel):
270
+ complete: bool
271
+ primary_line: str | None = None
272
+ source: str | None = None
273
+ file_path: str | None = None
274
+ selection_rule: str | None = None
275
+ full_context: str | None = None
276
+ copyable_prompt: str | None = None
277
+ reason: str | None = None