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,6 @@
1
+ """SIN-Code Bundle - Unified SOTA Agent-Engineering Stack.
2
+
3
+ Docs: __init__.doc.md
4
+ """
5
+
6
+ __version__ = "0.8.1"
@@ -0,0 +1,245 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Generator fuer eine AGENTS.md (WS4, Issue #4).
3
+
4
+ OpenCode und Codex lesen automatisch eine ``AGENTS.md`` im Repo-Root. Dieser
5
+ Generator schreibt einen SIN-Code-Block, der dem Agenten erklaert, *wann*
6
+ welches SIN-Tool aufzurufen ist. Der Block ist zwischen Markern eingefasst und
7
+ wird idempotent ersetzt -- der restliche Inhalt der Datei bleibt unangetastet.
8
+
9
+ Docs: agents_md.doc.md
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ START_MARKER = "<!-- sin:start -->"
17
+ END_MARKER = "<!-- sin:end -->"
18
+
19
+ # ── Playbooks (situation → tool) ───────────────────────────────────────────
20
+ # Mapping: wann welches Tool. Bewusst knapp und handlungsorientiert.
21
+ _PLAYBOOK = [
22
+ (
23
+ "Before refactoring or deleting a symbol",
24
+ "impact",
25
+ "Get the blast radius (downstream dependents) before you change a shared symbol.",
26
+ ),
27
+ (
28
+ "After producing a diff / before committing",
29
+ "semantic_review",
30
+ "Summarize the intent and risk of the change instead of eyeballing line diffs.",
31
+ ),
32
+ (
33
+ "Before merging or marking a task done",
34
+ "verify_tests",
35
+ "Run independent, execution-based verification. Never trust a self-reported 'done'.",
36
+ ),
37
+ (
38
+ "When you need correctness guarantees",
39
+ "prove",
40
+ "Generate and check properties/proofs for pure functions.",
41
+ ),
42
+ (
43
+ "When code needs external services in tests",
44
+ "mock_env",
45
+ "Spin up an ephemeral full-stack mock environment, then tear it down.",
46
+ ),
47
+ (
48
+ "To understand overall code health",
49
+ "architectural_debt",
50
+ "Check the current architectural debt score before large changes.",
51
+ ),
52
+ ]
53
+
54
+ # Memory playbook — only surfaced when SIN-Brain is installed (BR-2, Issue #15).
55
+ _MEMORY_PLAYBOOK = [
56
+ (
57
+ "Before starting a task",
58
+ "recall",
59
+ "Pull prior decisions, conventions and known pitfalls for this area first.",
60
+ ),
61
+ (
62
+ "After making a non-obvious decision",
63
+ "remember",
64
+ "Persist the decision/convention/fix so future turns do not relitigate it.",
65
+ ),
66
+ (
67
+ "When a subsystem returns a verdict",
68
+ "link_evidence",
69
+ "Attach the oracle/poc/ibd/sckg/adw verdict to the affected code entity.",
70
+ ),
71
+ (
72
+ "When a memory is stale or wrong",
73
+ "forget",
74
+ "Remove outdated memories; `pin` the ones that must never be evicted.",
75
+ ),
76
+ ]
77
+
78
+
79
+ # Red-zones: hard "do not" constraints. Negative constraints reliably steer
80
+ # agents away from anti-patterns far better than positive guidance alone.
81
+ _NEGATIVE_CONSTRAINTS = [
82
+ "Do **not** mark a task done or merge without a passing `verify_tests` run.",
83
+ "Do **not** refactor or delete a shared symbol before checking `impact`.",
84
+ "Do **not** invent file paths, APIs or symbols — confirm via `recall` / graph context.",
85
+ "Do **not** discard a memory verdict to make a change look clean.",
86
+ "Do **not** weaken or delete tests to get them to pass.",
87
+ "Do **not** commit secrets, tokens or credentials.",
88
+ ]
89
+
90
+
91
+ # ── Block Builder + Public Renderers ───────────────────────────────────────
92
+ def _build_block(memory_available: bool = False, inject_text: str = "") -> str:
93
+ """Baut den Inhalt zwischen den Markern (ohne die Marker selbst).
94
+
95
+ ``memory_available`` schaltet die Memory-Playbook-Zeilen frei; ``inject_text``
96
+ ist der von SIN-Brain gelieferte Kontext-Block (SB-4), der unveraendert
97
+ eingebettet wird.
98
+ """
99
+ lines = [
100
+ "## SIN-Code Agent Tooling",
101
+ "",
102
+ "This repository is wired to the SIN-Code verification stack via MCP",
103
+ "(`sin serve`). Use these tools proactively -- they are cheap signals that",
104
+ "prevent shipping broken code.",
105
+ "",
106
+ "### SIN-Brain Memory Protocol",
107
+ "",
108
+ "The project uses a 4-tier memory system:",
109
+ "- **Core**: Critical conventions (always recalled)",
110
+ "- **Recall**: Recent context (last 7 days)",
111
+ "- **Episodic**: Task history (last 30 days)",
112
+ "- **Consolidated**: Long-term knowledge (summaries)",
113
+ "",
114
+ "Call `recall` before starting work. Call `remember` after completing.",
115
+ "",
116
+ "### Memory Rules",
117
+ "",
118
+ "1. **Always recall first.** Check for existing context.",
119
+ "2. **Remember conventions.** Store patterns that caused bugs.",
120
+ "3. **Pin critical rules.** Use `pin` for must-remember items.",
121
+ "4. **Link evidence.** Connect related memories with `link_evidence`.",
122
+ "",
123
+ "### When to call which tool",
124
+ "",
125
+ "| Situation | Tool | Why |",
126
+ "| --- | --- | --- |",
127
+ ]
128
+ playbook = list(_PLAYBOOK)
129
+ if memory_available:
130
+ playbook += _MEMORY_PLAYBOOK
131
+ for situation, tool, why in playbook:
132
+ lines.append(f"| {situation} | `{tool}` | {why} |")
133
+
134
+ lines += [
135
+ "",
136
+ "### Rules",
137
+ "",
138
+ "1. **Do not claim success without `verify_tests`.** A green compile is not proof.",
139
+ "2. **Run `impact` before touching shared code** to avoid silent breakage.",
140
+ "3. **Prefer `semantic_review` over raw diffs** when assessing your own changes.",
141
+ "4. If a tool is unavailable, continue gracefully and say so explicitly.",
142
+ "5. **Recall before you code.** Always check SIN-Brain for relevant context.",
143
+ "6. **Remember after you learn.** Store conventions and pitfalls in memory.",
144
+ ]
145
+ if memory_available:
146
+ lines += [
147
+ "5. **Start with `recall` and persist decisions with `remember`** so the",
148
+ " project's memory compounds across sessions.",
149
+ ]
150
+
151
+ lines += [
152
+ "",
153
+ "### Negative constraints (red-zones)",
154
+ "",
155
+ ]
156
+ lines += [f"- {c}" for c in _NEGATIVE_CONSTRAINTS]
157
+
158
+ if inject_text.strip():
159
+ lines += [
160
+ "",
161
+ "### Project memory (SIN-Brain)",
162
+ "",
163
+ "<!-- Injected by `sin agents-md` from SIN-Brain; regenerated each run. -->",
164
+ inject_text.strip(),
165
+ ]
166
+
167
+ return "\n".join(lines)
168
+
169
+
170
+ def _memory_context() -> tuple[bool, str]:
171
+ """Return (sin-brain available?, inject text) from the memory adapter.
172
+
173
+ Defensive: any failure degrades to (False, "") so `sin agents-md` always
174
+ produces a valid file even without SIN-Brain installed.
175
+ """
176
+ try:
177
+ from sin_code_bundle import memory
178
+
179
+ env = memory.detect_env()
180
+ if not env.available:
181
+ return False, ""
182
+ inject = ""
183
+ getter = getattr(memory, "inject", None)
184
+ if callable(getter):
185
+ try:
186
+ inject = getter() or ""
187
+ except Exception: # noqa: BLE001 - inject must never break generation
188
+ inject = ""
189
+ return True, inject
190
+ except Exception: # noqa: BLE001
191
+ return False, ""
192
+
193
+
194
+ def render_block(memory_available: bool | None = None, inject_text: str | None = None) -> str:
195
+ """Vollstaendiger, markierter SIN-Block (inkl. Marker)."""
196
+ if memory_available is None or inject_text is None:
197
+ detected_available, detected_inject = _memory_context()
198
+ memory_available = detected_available if memory_available is None else memory_available
199
+ inject_text = detected_inject if inject_text is None else inject_text
200
+ body = _build_block(memory_available=memory_available, inject_text=inject_text)
201
+ return f"{START_MARKER}\n{body}\n{END_MARKER}"
202
+
203
+
204
+ def render_full_document(
205
+ memory_available: bool | None = None, inject_text: str | None = None
206
+ ) -> str:
207
+ """Eine komplette AGENTS.md fuer den Fall, dass noch keine existiert."""
208
+ header = "# AGENTS.md\n\nGuidance for AI coding agents working in this repository.\n"
209
+ block = render_block(memory_available=memory_available, inject_text=inject_text)
210
+ return f"{header}\n{block}\n"
211
+
212
+
213
+ def upsert(path: Path) -> str:
214
+ """Schreibt/aktualisiert die AGENTS.md idempotent.
215
+
216
+ - Datei fehlt -> komplette Vorlage mit SIN-Block anlegen.
217
+ - Datei ohne Block -> SIN-Block am Ende anhaengen.
218
+ - Datei mit Block -> nur den Bereich zwischen den Markern ersetzen.
219
+
220
+ Gibt eine kurze Statusmeldung zurueck.
221
+ """
222
+ mem_available, inject_text = _memory_context()
223
+ block = render_block(memory_available=mem_available, inject_text=inject_text)
224
+ if not path.exists():
225
+ path.parent.mkdir(parents=True, exist_ok=True)
226
+ path.write_text(
227
+ render_full_document(memory_available=mem_available, inject_text=inject_text)
228
+ )
229
+ return f"Created {path} with SIN-Code block"
230
+
231
+ content = path.read_text()
232
+ if START_MARKER in content and END_MARKER in content:
233
+ start = content.index(START_MARKER)
234
+ end = content.index(END_MARKER) + len(END_MARKER)
235
+ new_content = content[:start] + block + content[end:]
236
+ if new_content == content:
237
+ path.write_text(new_content)
238
+ return f"{path} already up to date (no change)"
239
+ path.write_text(new_content)
240
+ return f"Updated SIN-Code block in {path}"
241
+
242
+ # Block fehlt: anhaengen, mit sauberem Abstand.
243
+ sep = "" if content.endswith("\n\n") else ("\n" if content.endswith("\n") else "\n\n")
244
+ path.write_text(content + sep + block + "\n")
245
+ return f"Appended SIN-Code block to {path}"
@@ -0,0 +1,323 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """AST-based code editing with lazy tree-sitter + POC verification.
3
+
4
+ Docs: ast_edit.doc.md
5
+
6
+ Tree-sitter is **optional**. If it isn't installed, :class:`SINASTEdit`
7
+ falls back to a no-op state where :meth:`is_available` returns ``False``
8
+ and :meth:`edit` returns a clear install-hint error. This keeps the
9
+ bundle importable without tree-sitter as a hard dep.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+
19
+ # ── Lazy Tree-sitter Loader ────────────────────────────────────────
20
+ # Tree-sitter is a heavy native dep; we never want to force it on
21
+ # users who only want to import :mod:`sin_code_bundle` for tooling.
22
+ # Lazy import = the bundle works even when tree-sitter is missing
23
+ # (graceful degradation via the ImportError catch below).
24
+ def _try_import_tree_sitter() -> Optional[Any]:
25
+ try:
26
+ import tree_sitter # type: ignore # noqa: F401
27
+
28
+ return tree_sitter
29
+ except ImportError:
30
+ return None
31
+
32
+
33
+ def _try_import_tree_sitter_languages() -> Optional[Any]:
34
+ try:
35
+ from tree_sitter_languages import get_parser # type: ignore # noqa: F401
36
+
37
+ return get_parser
38
+ except ImportError:
39
+ return None
40
+
41
+
42
+ # ── ASTEditResult: Edit Outcome ────────────────────────────────────
43
+ class ASTEditResult:
44
+ """Result of an AST edit operation.
45
+
46
+ Attributes:
47
+ success: True if the proposal phase succeeded.
48
+ proposed_changes: List of change dicts ready to be applied
49
+ via :meth:`SINASTEdit.resolve`.
50
+ poc_verified: True if a POC verification call succeeded.
51
+ poc_report: The raw POC report (or ``None``).
52
+ error: Human-readable error message on failure.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ success: bool,
58
+ proposed_changes: Optional[List[Dict[str, Any]]] = None,
59
+ poc_verified: bool = False,
60
+ poc_report: Optional[Dict[str, Any]] = None,
61
+ error: Optional[str] = None,
62
+ ) -> None:
63
+ self.success = success
64
+ self.proposed_changes = proposed_changes or []
65
+ self.poc_verified = poc_verified
66
+ self.poc_report = poc_report
67
+ self.error = error
68
+
69
+ def to_dict(self) -> Dict[str, Any]:
70
+ """Serialize to a JSON-safe dict for transport/storage.
71
+
72
+ Round-trips through ``json.dumps``/``json.loads``. Strips no
73
+ fields — every public attribute appears in the dict verbatim
74
+ (including ``None`` values for unset optional fields).
75
+ """
76
+ return {
77
+ "success": self.success,
78
+ "proposed_changes": self.proposed_changes,
79
+ "poc_verified": self.poc_verified,
80
+ "poc_report": self.poc_report,
81
+ "error": self.error,
82
+ }
83
+
84
+
85
+ # ── SINASTEdit: Tree-sitter-Powered Edits ───────────────────────────
86
+ class SINASTEdit:
87
+ """AST-based code editing with tree-sitter and POC verification.
88
+
89
+ Usage:
90
+ ast = SINASTEdit()
91
+ result = ast.edit(Path("foo.py"), "def old_func():", "def new_func():")
92
+ if result.success:
93
+ ast.resolve(Path("foo.py"), result.proposed_changes)
94
+
95
+ Tree-sitter must be installed for any real editing::
96
+
97
+ pip install tree-sitter tree-sitter-languages
98
+ """
99
+
100
+ # 5 languages we know tree-sitter-languages supports reliably
101
+ # (this list is conservative — adding a language here is cheap,
102
+ # but adding one that the installed tree-sitter-languages doesn't
103
+ # ship just means it silently drops out in _init_parsers below).
104
+ SUPPORTED_LANGS = {"python", "javascript", "typescript", "go", "rust"}
105
+
106
+ def __init__(self, repo_root: Optional[Path] = None) -> None:
107
+ self.repo_root = repo_root or Path.cwd()
108
+ self.ts: Optional[Any] = _try_import_tree_sitter()
109
+ self.get_parser: Optional[Any] = _try_import_tree_sitter_languages()
110
+ self.parsers: Dict[str, Any] = {}
111
+ if self.ts is not None and self.get_parser is not None:
112
+ self._init_parsers()
113
+
114
+ def _init_parsers(self) -> None:
115
+ """Initialize tree-sitter parsers for :data:`SUPPORTED_LANGS`.
116
+
117
+ Missing or broken language bindings are skipped silently — the
118
+ affected language simply won't appear in :attr:`parsers`.
119
+ """
120
+ if self.get_parser is None:
121
+ return
122
+ for lang in self.SUPPORTED_LANGS:
123
+ try:
124
+ self.parsers[lang] = self.get_parser(lang)
125
+ except Exception:
126
+ # Individual language failures don't break the whole
127
+ # module: tree-sitter-languages may not ship every
128
+ # grammar for every wheel. The user just loses one
129
+ # language from `is_available()`.
130
+ pass
131
+
132
+ @staticmethod
133
+ def _detect_language(file_path: Path) -> Optional[str]:
134
+ # Static because there's no instance state needed — just a
135
+ # simple extension-to-language mapping table.
136
+ ext_map = {
137
+ ".py": "python",
138
+ ".js": "javascript",
139
+ ".ts": "typescript",
140
+ ".go": "go",
141
+ ".rs": "rust",
142
+ }
143
+ return ext_map.get(file_path.suffix)
144
+
145
+ def is_available(self, language: Optional[str] = None) -> bool:
146
+ """Return whether AST editing is available.
147
+
148
+ With no ``language`` argument, returns True if *any* supported
149
+ parser loaded successfully. With a ``language``, returns True
150
+ only if that specific parser is ready.
151
+
152
+ This method intentionally does NOT raise — callers need a
153
+ safe way to probe support without try/except around every
154
+ edit attempt.
155
+ """
156
+ if self.ts is None or self.get_parser is None:
157
+ return False
158
+ if language is None:
159
+ return bool(self.parsers)
160
+ return language in self.parsers
161
+
162
+ def edit(
163
+ self,
164
+ file_path: Path,
165
+ old_substring: str,
166
+ replacement: str,
167
+ verify_with_poc: bool = True,
168
+ ) -> ASTEditResult:
169
+ """Propose an AST-based edit.
170
+
171
+ Tree-sitter is used to parse the file and confirm the language
172
+ is supported, but the actual replacement is line-based (the
173
+ line containing ``old_substring`` is swapped for ``replacement``).
174
+ That keeps the v1 simple while still validating syntax.
175
+
176
+ Install for full AST-precise edits::
177
+
178
+ pip install tree-sitter tree-sitter-languages
179
+ """
180
+ file_path = Path(file_path)
181
+ if not file_path.exists():
182
+ return ASTEditResult(success=False, error=f"File not found: {file_path}")
183
+ if not self.is_available():
184
+ return ASTEditResult(
185
+ success=False,
186
+ error=(
187
+ "tree-sitter not installed. Run: pip install tree-sitter tree-sitter-languages"
188
+ ),
189
+ )
190
+ language = self._detect_language(file_path)
191
+ if not language or language not in self.parsers:
192
+ return ASTEditResult(
193
+ success=False,
194
+ error=f"Unsupported or unparsed language for: {file_path}",
195
+ )
196
+ parser = self.parsers[language]
197
+
198
+ code = file_path.read_text()
199
+ # Parse the whole file — throws away the tree after, but proves
200
+ # the file is syntactically valid for the chosen language
201
+ # (tree-sitter's parse() raises on invalid syntax for supported
202
+ # languages; for our loose mode we just rely on it not raising).
203
+ parser.parse(bytes(code, "utf-8"))
204
+
205
+ if old_substring not in code:
206
+ return ASTEditResult(
207
+ success=False,
208
+ error=f"old_substring not found in {file_path}",
209
+ )
210
+ # Line-based replacement (not byte-range): tree-sitter's query
211
+ # API for surgical byte-range edits is complex and varies by
212
+ # grammar. For the v1 we use a line-based fallback that still
213
+ # validates AST context (the parse above) before applying.
214
+ lines = code.splitlines(keepends=True)
215
+ target_line: Optional[int] = None
216
+ for i, line in enumerate(lines):
217
+ if old_substring in line:
218
+ target_line = i
219
+ break
220
+ if target_line is None:
221
+ return ASTEditResult(success=False, error="Could not locate line")
222
+
223
+ # Preserve line ending of the original line
224
+ new_line = replacement + ("\n" if lines[target_line].endswith("\n") else "")
225
+ proposed: List[Dict[str, Any]] = [
226
+ {
227
+ "type": "ast_replacement",
228
+ "line": target_line,
229
+ "old": lines[target_line],
230
+ "new": new_line,
231
+ "language": language,
232
+ }
233
+ ]
234
+
235
+ poc_verified = False
236
+ poc_report: Optional[Dict[str, Any]] = None
237
+ if verify_with_poc:
238
+ poc_report, poc_verified = self._verify_with_poc()
239
+ else:
240
+ poc_report = {"verified": "skipped", "note": "verify_with_poc=False"}
241
+
242
+ return ASTEditResult(
243
+ success=True,
244
+ proposed_changes=proposed,
245
+ poc_verified=poc_verified,
246
+ poc_report=poc_report,
247
+ )
248
+
249
+ def _verify_with_poc(self) -> tuple[Dict[str, Any], bool]:
250
+ """Best-effort POC verification using the optional ``sin_code_poc``.
251
+
252
+ Returns ``(report, verified)``. Never raises; on ImportError
253
+ reports ``verified: skipped``. On any other failure reports
254
+ ``verified: failed`` with the error string.
255
+
256
+ Note: this is intentionally lenient — POC's exact API may vary
257
+ across versions, so we capture *presence* (is it installed?)
258
+ and *size* (how many properties does it expose?) rather than
259
+ strict semantic verification. Callers needing strict checks
260
+ should call POC directly.
261
+ """
262
+ try:
263
+ from sin_code_poc import property_metadata # type: ignore
264
+
265
+ props: Any = property_metadata() if callable(property_metadata) else {}
266
+ n = len(props) if hasattr(props, "__len__") else 0
267
+ return (
268
+ {
269
+ "verified": "ok",
270
+ "note": f"POC available, {n} properties",
271
+ },
272
+ True,
273
+ )
274
+ except ImportError:
275
+ return ({"verified": "skipped", "error": "POC not installed"}, False)
276
+ except Exception as e: # noqa: BLE001
277
+ return ({"verified": "failed", "error": str(e)}, False)
278
+
279
+ def resolve(self, file_path: Path, changes: List[Dict[str, Any]]) -> bool:
280
+ """Apply accepted AST changes to a file.
281
+
282
+ Changes are applied in reverse line order so earlier line
283
+ numbers stay valid. The write is atomic via a sibling
284
+ ``tempfile.NamedTemporaryFile`` + ``Path.replace``.
285
+
286
+ Returns True on success, False on any I/O failure.
287
+ """
288
+ file_path = Path(file_path)
289
+ if not file_path.exists():
290
+ return False
291
+ code = file_path.read_text()
292
+ lines = code.splitlines(keepends=True)
293
+ # Apply changes in reverse order so line numbers stay valid
294
+ # (editing line 100 first would shift line 50's index if we
295
+ # went forward — reversing avoids that bookkeeping).
296
+ sorted_changes = sorted(changes, key=lambda c: c["line"], reverse=True)
297
+ for change in sorted_changes:
298
+ idx = change["line"]
299
+ if 0 <= idx < len(lines):
300
+ lines[idx] = change["new"]
301
+ modified = "".join(lines)
302
+ # Atomic write: tmp in same dir, then replace (sibling-file
303
+ # rename is atomic on POSIX; same pattern as hashline.py).
304
+ try:
305
+ with tempfile.NamedTemporaryFile(
306
+ mode="w",
307
+ dir=file_path.parent,
308
+ delete=False,
309
+ suffix=".tmp",
310
+ ) as tmp:
311
+ tmp.write(modified)
312
+ tmp_path = Path(tmp.name)
313
+ except OSError:
314
+ return False
315
+ try:
316
+ tmp_path.replace(file_path)
317
+ return True
318
+ except OSError:
319
+ tmp_path.unlink(missing_ok=True)
320
+ return False
321
+
322
+
323
+ __all__ = ["SINASTEdit", "ASTEditResult"]