atomadic-forge 0.3.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 (131) hide show
  1. atomadic_forge/__init__.py +12 -0
  2. atomadic_forge/__main__.py +5 -0
  3. atomadic_forge/a0_qk_constants/__init__.py +1 -0
  4. atomadic_forge/a0_qk_constants/agent_plan_schema.py +120 -0
  5. atomadic_forge/a0_qk_constants/commandsmith_types.py +49 -0
  6. atomadic_forge/a0_qk_constants/config_defaults.py +38 -0
  7. atomadic_forge/a0_qk_constants/emergent_types.py +77 -0
  8. atomadic_forge/a0_qk_constants/error_codes.py +296 -0
  9. atomadic_forge/a0_qk_constants/forge_types.py +89 -0
  10. atomadic_forge/a0_qk_constants/gen_language.py +116 -0
  11. atomadic_forge/a0_qk_constants/lang_extensions.py +150 -0
  12. atomadic_forge/a0_qk_constants/policy_schema.py +48 -0
  13. atomadic_forge/a0_qk_constants/receipt_schema.py +311 -0
  14. atomadic_forge/a0_qk_constants/roi_constants.py +96 -0
  15. atomadic_forge/a0_qk_constants/semantic_types.py +61 -0
  16. atomadic_forge/a0_qk_constants/sidecar_schema.py +81 -0
  17. atomadic_forge/a0_qk_constants/synergy_types.py +62 -0
  18. atomadic_forge/a0_qk_constants/tier_names.py +47 -0
  19. atomadic_forge/a1_at_functions/__init__.py +1 -0
  20. atomadic_forge/a1_at_functions/agent_context_pack.py +193 -0
  21. atomadic_forge/a1_at_functions/agent_memory.py +139 -0
  22. atomadic_forge/a1_at_functions/agent_plan_emitter.py +324 -0
  23. atomadic_forge/a1_at_functions/agent_summary.py +277 -0
  24. atomadic_forge/a1_at_functions/body_extractor.py +306 -0
  25. atomadic_forge/a1_at_functions/card_renderer.py +210 -0
  26. atomadic_forge/a1_at_functions/certify_checks.py +445 -0
  27. atomadic_forge/a1_at_functions/chat_context.py +170 -0
  28. atomadic_forge/a1_at_functions/cherry_pick.py +71 -0
  29. atomadic_forge/a1_at_functions/classify_tier.py +115 -0
  30. atomadic_forge/a1_at_functions/commandsmith_discover.py +167 -0
  31. atomadic_forge/a1_at_functions/commandsmith_render.py +267 -0
  32. atomadic_forge/a1_at_functions/compiler_feedback.py +94 -0
  33. atomadic_forge/a1_at_functions/compliance_checker.py +228 -0
  34. atomadic_forge/a1_at_functions/config_io.py +68 -0
  35. atomadic_forge/a1_at_functions/cs1_renderer.py +588 -0
  36. atomadic_forge/a1_at_functions/doc_synthesizer.py +205 -0
  37. atomadic_forge/a1_at_functions/emergent_compose.py +192 -0
  38. atomadic_forge/a1_at_functions/emergent_rank.py +116 -0
  39. atomadic_forge/a1_at_functions/emergent_signature_extract.py +242 -0
  40. atomadic_forge/a1_at_functions/emergent_synthesize.py +88 -0
  41. atomadic_forge/a1_at_functions/enforce_planner.py +208 -0
  42. atomadic_forge/a1_at_functions/error_hints.py +105 -0
  43. atomadic_forge/a1_at_functions/evolution_log.py +94 -0
  44. atomadic_forge/a1_at_functions/forge_feedback.py +433 -0
  45. atomadic_forge/a1_at_functions/generation_quality.py +322 -0
  46. atomadic_forge/a1_at_functions/import_repair.py +211 -0
  47. atomadic_forge/a1_at_functions/import_smoke.py +102 -0
  48. atomadic_forge/a1_at_functions/js_parser.py +539 -0
  49. atomadic_forge/a1_at_functions/lineage_chain.py +144 -0
  50. atomadic_forge/a1_at_functions/lineage_reader.py +107 -0
  51. atomadic_forge/a1_at_functions/llm_client.py +554 -0
  52. atomadic_forge/a1_at_functions/local_signer.py +134 -0
  53. atomadic_forge/a1_at_functions/lsp_protocol.py +379 -0
  54. atomadic_forge/a1_at_functions/manifest_diff.py +314 -0
  55. atomadic_forge/a1_at_functions/mcp_protocol.py +1066 -0
  56. atomadic_forge/a1_at_functions/patch_scorer.py +267 -0
  57. atomadic_forge/a1_at_functions/plan_adapter.py +75 -0
  58. atomadic_forge/a1_at_functions/policy_loader.py +107 -0
  59. atomadic_forge/a1_at_functions/preflight_change.py +227 -0
  60. atomadic_forge/a1_at_functions/progress_reporter.py +81 -0
  61. atomadic_forge/a1_at_functions/provider_detect.py +157 -0
  62. atomadic_forge/a1_at_functions/provider_resolver.py +48 -0
  63. atomadic_forge/a1_at_functions/receipt_emitter.py +291 -0
  64. atomadic_forge/a1_at_functions/recipes.py +186 -0
  65. atomadic_forge/a1_at_functions/repo_explainer.py +124 -0
  66. atomadic_forge/a1_at_functions/roi_calculator.py +265 -0
  67. atomadic_forge/a1_at_functions/rollback_planner.py +147 -0
  68. atomadic_forge/a1_at_functions/sbom_emitter.py +155 -0
  69. atomadic_forge/a1_at_functions/scaffold_js.py +55 -0
  70. atomadic_forge/a1_at_functions/scaffold_pyproject.py +62 -0
  71. atomadic_forge/a1_at_functions/scaffold_starter.py +94 -0
  72. atomadic_forge/a1_at_functions/scout_walk.py +309 -0
  73. atomadic_forge/a1_at_functions/sidecar_parser.py +161 -0
  74. atomadic_forge/a1_at_functions/sidecar_validator.py +202 -0
  75. atomadic_forge/a1_at_functions/stub_detector.py +158 -0
  76. atomadic_forge/a1_at_functions/synergy_detect.py +166 -0
  77. atomadic_forge/a1_at_functions/synergy_render.py +252 -0
  78. atomadic_forge/a1_at_functions/synergy_surface_extract.py +163 -0
  79. atomadic_forge/a1_at_functions/test_runner.py +196 -0
  80. atomadic_forge/a1_at_functions/test_selector.py +122 -0
  81. atomadic_forge/a1_at_functions/tier_init_rebuild.py +122 -0
  82. atomadic_forge/a1_at_functions/tool_composer.py +130 -0
  83. atomadic_forge/a1_at_functions/transcript_log.py +70 -0
  84. atomadic_forge/a1_at_functions/wire_check.py +260 -0
  85. atomadic_forge/a2_mo_composites/__init__.py +1 -0
  86. atomadic_forge/a2_mo_composites/lineage_chain_store.py +122 -0
  87. atomadic_forge/a2_mo_composites/manifest_store.py +46 -0
  88. atomadic_forge/a2_mo_composites/plan_store.py +164 -0
  89. atomadic_forge/a2_mo_composites/receipt_signer.py +231 -0
  90. atomadic_forge/a3_og_features/__init__.py +1 -0
  91. atomadic_forge/a3_og_features/commandsmith_feature.py +267 -0
  92. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/__init__.py +3 -0
  93. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a0_qk_constants/__init__.py +4 -0
  94. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a1_at_functions/__init__.py +14 -0
  95. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/conftest.py +10 -0
  96. atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/test_mixed.py +18 -0
  97. atomadic_forge/a3_og_features/demo_runner.py +502 -0
  98. atomadic_forge/a3_og_features/emergent_feature.py +95 -0
  99. atomadic_forge/a3_og_features/emergent_pipeline_integration.py +154 -0
  100. atomadic_forge/a3_og_features/forge_enforce.py +107 -0
  101. atomadic_forge/a3_og_features/forge_evolve.py +176 -0
  102. atomadic_forge/a3_og_features/forge_loop.py +528 -0
  103. atomadic_forge/a3_og_features/forge_pipeline.py +295 -0
  104. atomadic_forge/a3_og_features/forge_plan_apply.py +222 -0
  105. atomadic_forge/a3_og_features/lsp_server.py +98 -0
  106. atomadic_forge/a3_og_features/mcp_server.py +160 -0
  107. atomadic_forge/a3_og_features/setup_wizard.py +337 -0
  108. atomadic_forge/a3_og_features/synergy_feature.py +65 -0
  109. atomadic_forge/a4_sy_orchestration/__init__.py +1 -0
  110. atomadic_forge/a4_sy_orchestration/cli.py +1284 -0
  111. atomadic_forge/commands/__init__.py +1 -0
  112. atomadic_forge/commands/_registry.py +36 -0
  113. atomadic_forge/commands/audit.py +142 -0
  114. atomadic_forge/commands/chat.py +133 -0
  115. atomadic_forge/commands/commandsmith.py +178 -0
  116. atomadic_forge/commands/config_cmd.py +145 -0
  117. atomadic_forge/commands/demo.py +142 -0
  118. atomadic_forge/commands/emergent.py +124 -0
  119. atomadic_forge/commands/emergent_then_synergy.py +70 -0
  120. atomadic_forge/commands/evolve.py +122 -0
  121. atomadic_forge/commands/evolve_then_iterate.py +70 -0
  122. atomadic_forge/commands/feature_then_emergent.py +111 -0
  123. atomadic_forge/commands/iterate.py +140 -0
  124. atomadic_forge/commands/synergy.py +96 -0
  125. atomadic_forge/commands/synergy_then_emergent.py +70 -0
  126. atomadic_forge-0.3.2.dist-info/METADATA +471 -0
  127. atomadic_forge-0.3.2.dist-info/RECORD +131 -0
  128. atomadic_forge-0.3.2.dist-info/WHEEL +5 -0
  129. atomadic_forge-0.3.2.dist-info/entry_points.txt +3 -0
  130. atomadic_forge-0.3.2.dist-info/licenses/LICENSE +15 -0
  131. atomadic_forge-0.3.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,134 @@
1
+ """Tier a1 — pure Ed25519 local signing for Forge Receipts.
2
+
3
+ Golden Path Lane G W5. Signs ``canonical_receipt_hash(receipt)`` with a
4
+ local Ed25519 private key (PEM); attaches the result to
5
+ ``receipt['signatures']['local_sign']``.
6
+
7
+ Soft-fail contract (mirrors the Lane A W2 AAAA-Nexus signer):
8
+ * 'cryptography' not installed -> receipt unchanged + notes entry, no raise
9
+ * key_path missing / unreadable -> receipt unchanged + notes entry, no raise
10
+ * key is not Ed25519 -> receipt unchanged + notes entry, no raise
11
+ * verify_receipt_local -> returns (ok, problems), never raises
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import datetime as _dt
17
+ import hashlib
18
+ from copy import deepcopy
19
+ from pathlib import Path
20
+
21
+ from ..a0_qk_constants.receipt_schema import (
22
+ ForgeReceiptV1,
23
+ ReceiptLocalSignSignature,
24
+ )
25
+ from .lineage_chain import canonical_receipt_hash
26
+
27
+
28
+ def sign_receipt_local(
29
+ receipt: ForgeReceiptV1,
30
+ *,
31
+ key_path: Path | str,
32
+ ) -> ForgeReceiptV1:
33
+ """Return a copy of ``receipt`` with ``signatures.local_sign`` populated.
34
+
35
+ Reads an Ed25519 private key from ``key_path`` (PEM, unencrypted),
36
+ signs ``canonical_receipt_hash(receipt)``, and attaches the block.
37
+ Soft-fails silently on any error.
38
+ """
39
+ out = deepcopy(receipt)
40
+
41
+ try:
42
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( # noqa: PLC0415
43
+ Ed25519PrivateKey,
44
+ )
45
+ from cryptography.hazmat.primitives.serialization import ( # noqa: PLC0415
46
+ Encoding,
47
+ PublicFormat,
48
+ load_pem_private_key,
49
+ )
50
+ except ImportError:
51
+ _note(out, "local_sign skipped: 'cryptography' package not installed")
52
+ return out
53
+
54
+ key_path = Path(key_path)
55
+ try:
56
+ pem_bytes = key_path.read_bytes()
57
+ private_key = load_pem_private_key(pem_bytes, password=None)
58
+ if not isinstance(private_key, Ed25519PrivateKey):
59
+ _note(out, f"local_sign skipped: {key_path.name} is not an Ed25519 key")
60
+ return out
61
+ except Exception as exc: # noqa: BLE001
62
+ _note(out, f"local_sign skipped: cannot load {key_path.name}: {type(exc).__name__}")
63
+ return out
64
+
65
+ receipt_hash_hex = canonical_receipt_hash(out)
66
+ hash_bytes = bytes.fromhex(receipt_hash_hex)
67
+
68
+ try:
69
+ sig_bytes = private_key.sign(hash_bytes)
70
+ except Exception as exc: # noqa: BLE001
71
+ _note(out, f"local_sign skipped: sign() raised {type(exc).__name__}")
72
+ return out
73
+
74
+ pub_key = private_key.public_key()
75
+ pub_raw = pub_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
76
+ key_id = hashlib.sha256(pub_raw).hexdigest()[:16]
77
+ signed_at = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
78
+
79
+ local_sig: ReceiptLocalSignSignature = {
80
+ "alg": "Ed25519",
81
+ "signature": base64.b64encode(sig_bytes).decode("ascii"),
82
+ "public_key": base64.b64encode(pub_raw).decode("ascii"),
83
+ "key_id": key_id,
84
+ "signed_at_utc": signed_at,
85
+ }
86
+ sigs = dict(out.get("signatures") or {})
87
+ sigs["local_sign"] = local_sig # type: ignore[typeddict-item]
88
+ out["signatures"] = sigs # type: ignore[typeddict-item]
89
+ return out
90
+
91
+
92
+ def verify_receipt_local(receipt: ForgeReceiptV1) -> tuple[bool, list[str]]:
93
+ """Verify the ``signatures.local_sign`` block in a Receipt.
94
+
95
+ Returns ``(True, [])`` when valid. Returns ``(False, [problem...])``
96
+ on any failure. Never raises.
97
+ """
98
+ sigs = receipt.get("signatures") or {}
99
+ local_sig = (sigs or {}).get("local_sign") # type: ignore[union-attr]
100
+ if local_sig is None:
101
+ return False, ["no local_sign block in receipt.signatures"]
102
+
103
+ try:
104
+ from cryptography.exceptions import InvalidSignature # noqa: PLC0415
105
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( # noqa: PLC0415
106
+ Ed25519PublicKey,
107
+ )
108
+ except ImportError:
109
+ return False, ["'cryptography' package not installed — cannot verify"]
110
+
111
+ try:
112
+ sig_bytes = base64.b64decode(local_sig["signature"])
113
+ pub_raw = base64.b64decode(local_sig["public_key"])
114
+ except Exception as exc: # noqa: BLE001
115
+ return False, [f"base64 decode failed: {exc}"]
116
+
117
+ receipt_hash_hex = canonical_receipt_hash(receipt)
118
+ hash_bytes = bytes.fromhex(receipt_hash_hex)
119
+
120
+ try:
121
+ pub_key = Ed25519PublicKey.from_public_bytes(pub_raw)
122
+ pub_key.verify(sig_bytes, hash_bytes)
123
+ except InvalidSignature:
124
+ return False, ["Ed25519 signature verification failed"]
125
+ except Exception as exc: # noqa: BLE001
126
+ return False, [f"verification error: {type(exc).__name__}: {exc}"]
127
+
128
+ return True, []
129
+
130
+
131
+ def _note(receipt: ForgeReceiptV1, message: str) -> None:
132
+ notes = list(receipt.get("notes") or [])
133
+ notes.append(message)
134
+ receipt["notes"] = notes # type: ignore[typeddict-item]
@@ -0,0 +1,379 @@
1
+ """Tier a1 — pure LSP JSON-RPC dispatcher for forge-lsp.
2
+
3
+ Golden Path Lane D W12 deliverable. The pure dispatcher lives here;
4
+ the stdio framing (Content-Length headers) lives in a3
5
+ ``lsp_server.py``.
6
+
7
+ Implements the slice of the LSP spec that gives `.forge` sidecar
8
+ files diagnostics + hover in VS Code / Neovim:
9
+
10
+ initialize / initialized — capability handshake
11
+ shutdown / exit — clean shutdown
12
+ textDocument/didOpen — open file → diagnostics
13
+ textDocument/didChange — re-validate on edit
14
+ textDocument/didSave — re-validate on save
15
+ textDocument/didClose — clear diagnostics
16
+ textDocument/hover — show sidecar symbol info
17
+ textDocument/publishDiagnostics — server-initiated; emitted
18
+ after each didOpen/Change/Save
19
+ textDocument/definition — goto-source from
20
+ `name: login` line in
21
+ foo.py.forge → foo.py:login
22
+
23
+ Pure: takes a request + an in-memory document store and returns
24
+ zero or more responses + zero or more notifications. The stdio
25
+ loop owns the actual reading + writing.
26
+
27
+ Dispatcher state lives in a tiny ``LspState`` dict (open documents
28
+ text by URI). It IS mutated across calls — that's fine; the I/O
29
+ boundary is a3, not a1, and the state is per-session.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import ast
34
+ import re
35
+ from pathlib import Path
36
+ from typing import Any, TypedDict
37
+ from urllib.parse import unquote, urlparse
38
+
39
+ from .. import __version__
40
+ from .sidecar_parser import parse_sidecar_text
41
+ from .sidecar_validator import validate_sidecar
42
+
43
+ SERVER_NAME = "forge-lsp"
44
+
45
+
46
+ # --- LSP error codes -------------------------------------------------------
47
+ _LSP_INVALID_REQUEST = -32600
48
+ _LSP_METHOD_NOT_FOUND = -32601
49
+ _LSP_INVALID_PARAMS = -32602
50
+ _LSP_INTERNAL = -32603
51
+
52
+
53
+ # --- session state --------------------------------------------------------
54
+
55
+ class LspState(TypedDict, total=False):
56
+ documents: dict[str, str] # uri → current text
57
+ initialized: bool
58
+ shutdown_requested: bool
59
+
60
+
61
+ def new_state() -> LspState:
62
+ return LspState(documents={}, initialized=False,
63
+ shutdown_requested=False)
64
+
65
+
66
+ # --- helpers --------------------------------------------------------------
67
+
68
+ def uri_to_path(uri: str) -> Path:
69
+ """Convert ``file:///c%3A/path/file.py`` to a Path."""
70
+ parsed = urlparse(uri)
71
+ path = unquote(parsed.path)
72
+ # Windows file URIs come through as /C:/...
73
+ if path.startswith("/") and len(path) > 2 and path[2] == ":":
74
+ path = path[1:]
75
+ return Path(path)
76
+
77
+
78
+ def _ok(msg_id: Any, result: Any) -> dict[str, Any]:
79
+ return {"jsonrpc": "2.0", "id": msg_id, "result": result}
80
+
81
+
82
+ def _err(msg_id: Any, code: int, message: str) -> dict[str, Any]:
83
+ return {"jsonrpc": "2.0", "id": msg_id,
84
+ "error": {"code": code, "message": message}}
85
+
86
+
87
+ def _notification(method: str, params: dict[str, Any]) -> dict[str, Any]:
88
+ return {"jsonrpc": "2.0", "method": method, "params": params}
89
+
90
+
91
+ # --- diagnostics ----------------------------------------------------------
92
+
93
+ def _line_for_symbol_in_sidecar(text: str, symbol_name: str) -> int:
94
+ """Best-effort: find the line a `name: <symbol_name>` appears on
95
+ in the sidecar YAML. 0-indexed for LSP. Returns 0 when not found."""
96
+ if not symbol_name:
97
+ return 0
98
+ # Look for `name: foo` or `- name: foo` patterns.
99
+ pattern = re.compile(rf"^\s*-?\s*name\s*:\s*{re.escape(symbol_name)}\s*$",
100
+ re.MULTILINE)
101
+ m = pattern.search(text)
102
+ if not m:
103
+ return 0
104
+ return text.count("\n", 0, m.start())
105
+
106
+
107
+ def compute_diagnostics(
108
+ *,
109
+ sidecar_text: str,
110
+ sidecar_uri: str,
111
+ ) -> list[dict[str, Any]]:
112
+ """Run the sidecar parser + validator against the in-memory text
113
+ and the sibling source file (resolved via the URI naming
114
+ convention foo.py.forge → foo.py). Returns LSP Diagnostic[].
115
+ """
116
+ diagnostics: list[dict[str, Any]] = []
117
+ parse = parse_sidecar_text(sidecar_text, source=sidecar_uri)
118
+ for err in parse.get("errors", []):
119
+ diagnostics.append({
120
+ "range": {"start": {"line": 0, "character": 0},
121
+ "end": {"line": 0, "character": 1}},
122
+ "severity": 1, # 1 = Error
123
+ "source": "forge-lsp",
124
+ "code": "F0100",
125
+ "message": err,
126
+ })
127
+ if parse.get("sidecar") is None:
128
+ return diagnostics
129
+
130
+ sidecar_path = uri_to_path(sidecar_uri)
131
+ # Strip the .forge suffix to get the source path.
132
+ if sidecar_path.suffix.lower() != ".forge":
133
+ return diagnostics
134
+ source_path = sidecar_path.with_suffix("")
135
+ if not source_path.exists():
136
+ diagnostics.append({
137
+ "range": {"start": {"line": 0, "character": 0},
138
+ "end": {"line": 0, "character": 1}},
139
+ "severity": 2, # 2 = Warning
140
+ "source": "forge-lsp",
141
+ "code": "F0100",
142
+ "message": (
143
+ f"sidecar present but source file not found at "
144
+ f"{source_path.name!r}; diagnostics limited"
145
+ ),
146
+ })
147
+ return diagnostics
148
+ try:
149
+ source_text = source_path.read_text(encoding="utf-8")
150
+ except OSError:
151
+ return diagnostics
152
+
153
+ rep = validate_sidecar(parse["sidecar"],
154
+ source_text=source_text,
155
+ source_path=source_path)
156
+ for f in rep.get("findings", []):
157
+ symbol = f.get("symbol", "")
158
+ line = _line_for_symbol_in_sidecar(sidecar_text, symbol)
159
+ sev = {"error": 1, "warn": 2, "info": 3}.get(f.get("severity", "warn"),
160
+ 2)
161
+ diagnostics.append({
162
+ "range": {"start": {"line": line, "character": 0},
163
+ "end": {"line": line, "character": 100}},
164
+ "severity": sev,
165
+ "source": "forge-lsp",
166
+ "code": f.get("f_code") or f.get("code", ""),
167
+ "message": f.get("message", ""),
168
+ })
169
+ return diagnostics
170
+
171
+
172
+ def compute_hover(
173
+ *,
174
+ sidecar_text: str,
175
+ line: int,
176
+ ) -> dict[str, Any] | None:
177
+ """Return an LSP Hover dict when the cursor is on a `name:`
178
+ line of a sidecar; otherwise None.
179
+
180
+ The hover content is markdown summarising the symbol's effect,
181
+ tier, compose_with, and proves clauses.
182
+ """
183
+ parse = parse_sidecar_text(sidecar_text)
184
+ if parse.get("sidecar") is None:
185
+ return None
186
+ lines = sidecar_text.splitlines()
187
+ if line < 0 or line >= len(lines):
188
+ return None
189
+ # Find which symbol block this line belongs to.
190
+ target_name: str | None = None
191
+ for i in range(line, -1, -1):
192
+ m = re.match(r"^\s*-?\s*name\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$",
193
+ lines[i])
194
+ if m:
195
+ target_name = m.group(1)
196
+ break
197
+ if not target_name:
198
+ return None
199
+ sc = parse["sidecar"]
200
+ sym = next((s for s in sc.get("symbols", [])
201
+ if s.get("name") == target_name), None)
202
+ if sym is None:
203
+ return None
204
+ md_parts = [
205
+ f"**{target_name}**",
206
+ f"effect: `{sym.get('effect', '?')}`",
207
+ ]
208
+ if sym.get("tier"):
209
+ md_parts.append(f"tier: `{sym['tier']}`")
210
+ if sym.get("compose_with"):
211
+ md_parts.append("composes with:\n"
212
+ + "\n".join(f"- `{c}`" for c in sym["compose_with"]))
213
+ if sym.get("proves"):
214
+ md_parts.append("proves:\n"
215
+ + "\n".join(f"- `{p}`" for p in sym["proves"]))
216
+ if sym.get("notes"):
217
+ md_parts.append("notes:\n"
218
+ + "\n".join(f"- {n}" for n in sym["notes"]))
219
+ return {
220
+ "contents": {"kind": "markdown", "value": "\n\n".join(md_parts)},
221
+ }
222
+
223
+
224
+ def compute_definition(
225
+ *,
226
+ sidecar_text: str,
227
+ sidecar_uri: str,
228
+ line: int,
229
+ ) -> dict[str, Any] | None:
230
+ """Resolve goto-source: `- name: login` line in foo.py.forge
231
+ → foo.py at the def/class line for `login`."""
232
+ lines = sidecar_text.splitlines()
233
+ if line < 0 or line >= len(lines):
234
+ return None
235
+ m = re.match(r"^\s*-?\s*name\s*:\s*([A-Za-z_][A-Za-z0-9_]*)\s*$",
236
+ lines[line])
237
+ if not m:
238
+ return None
239
+ target_name = m.group(1)
240
+ sidecar_path = uri_to_path(sidecar_uri)
241
+ if sidecar_path.suffix.lower() != ".forge":
242
+ return None
243
+ source_path = sidecar_path.with_suffix("")
244
+ if not source_path.exists():
245
+ return None
246
+ try:
247
+ source_text = source_path.read_text(encoding="utf-8")
248
+ except OSError:
249
+ return None
250
+ try:
251
+ tree = ast.parse(source_text)
252
+ except SyntaxError:
253
+ return None
254
+ for node in tree.body:
255
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) and node.name == target_name:
256
+ return {
257
+ "uri": "file:///" + str(source_path).replace("\\", "/"),
258
+ "range": {
259
+ "start": {"line": (node.lineno or 1) - 1, "character": 0},
260
+ "end": {"line": (node.lineno or 1) - 1,
261
+ "character": 100},
262
+ },
263
+ }
264
+ return None
265
+
266
+
267
+ # --- dispatch -------------------------------------------------------------
268
+
269
+ def dispatch_request(
270
+ request: dict[str, Any],
271
+ *,
272
+ state: LspState,
273
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
274
+ """Route one LSP request and return (responses, notifications).
275
+
276
+ notifications are server-initiated messages (e.g. publishDiagnostics)
277
+ emitted in response to didOpen / didChange / didSave.
278
+ """
279
+ if not isinstance(request, dict):
280
+ return ([_err(None, _LSP_INVALID_REQUEST,
281
+ "request must be a JSON object")], [])
282
+ method = request.get("method")
283
+ msg_id = request.get("id")
284
+ is_notification = "id" not in request
285
+ params = request.get("params") or {}
286
+ if not isinstance(method, str):
287
+ return ([_err(msg_id, _LSP_INVALID_REQUEST,
288
+ "request missing string `method`")], [])
289
+
290
+ if method == "initialize":
291
+ state["initialized"] = True
292
+ return ([_ok(msg_id, {
293
+ "capabilities": {
294
+ "textDocumentSync": {"openClose": True, "change": 1,
295
+ "save": True},
296
+ "hoverProvider": True,
297
+ "definitionProvider": True,
298
+ },
299
+ "serverInfo": {"name": SERVER_NAME, "version": __version__},
300
+ })], [])
301
+
302
+ if method == "initialized":
303
+ return ([], []) # client → server; no reply
304
+
305
+ if method == "shutdown":
306
+ state["shutdown_requested"] = True
307
+ return ([_ok(msg_id, None)], [])
308
+
309
+ if method == "exit":
310
+ return ([], [])
311
+
312
+ if method == "textDocument/didOpen":
313
+ td = params.get("textDocument") or {}
314
+ uri = td.get("uri", "")
315
+ text = td.get("text", "")
316
+ state.setdefault("documents", {})[uri] = text
317
+ diagnostics = compute_diagnostics(sidecar_text=text,
318
+ sidecar_uri=uri)
319
+ return ([], [_notification("textDocument/publishDiagnostics", {
320
+ "uri": uri, "diagnostics": diagnostics,
321
+ })])
322
+
323
+ if method == "textDocument/didChange":
324
+ td = params.get("textDocument") or {}
325
+ uri = td.get("uri", "")
326
+ changes = params.get("contentChanges") or []
327
+ if changes and isinstance(changes[-1].get("text"), str):
328
+ # We advertised TextDocumentSyncKind.Full (1), so the last
329
+ # change is the full new text.
330
+ text = changes[-1]["text"]
331
+ state.setdefault("documents", {})[uri] = text
332
+ diagnostics = compute_diagnostics(sidecar_text=text,
333
+ sidecar_uri=uri)
334
+ return ([], [_notification("textDocument/publishDiagnostics", {
335
+ "uri": uri, "diagnostics": diagnostics,
336
+ })])
337
+ return ([], [])
338
+
339
+ if method == "textDocument/didSave":
340
+ td = params.get("textDocument") or {}
341
+ uri = td.get("uri", "")
342
+ text = state.get("documents", {}).get(uri, "")
343
+ diagnostics = compute_diagnostics(sidecar_text=text,
344
+ sidecar_uri=uri)
345
+ return ([], [_notification("textDocument/publishDiagnostics", {
346
+ "uri": uri, "diagnostics": diagnostics,
347
+ })])
348
+
349
+ if method == "textDocument/didClose":
350
+ td = params.get("textDocument") or {}
351
+ uri = td.get("uri", "")
352
+ state.setdefault("documents", {}).pop(uri, None)
353
+ return ([], [_notification("textDocument/publishDiagnostics", {
354
+ "uri": uri, "diagnostics": [], # clear
355
+ })])
356
+
357
+ if method == "textDocument/hover":
358
+ td = params.get("textDocument") or {}
359
+ pos = params.get("position") or {}
360
+ uri = td.get("uri", "")
361
+ text = state.get("documents", {}).get(uri, "")
362
+ line = int(pos.get("line", 0))
363
+ hover = compute_hover(sidecar_text=text, line=line)
364
+ return ([_ok(msg_id, hover)], [])
365
+
366
+ if method == "textDocument/definition":
367
+ td = params.get("textDocument") or {}
368
+ pos = params.get("position") or {}
369
+ uri = td.get("uri", "")
370
+ text = state.get("documents", {}).get(uri, "")
371
+ line = int(pos.get("line", 0))
372
+ loc = compute_definition(sidecar_text=text, sidecar_uri=uri,
373
+ line=line)
374
+ return ([_ok(msg_id, loc)], [])
375
+
376
+ if is_notification:
377
+ return ([], [])
378
+ return ([_err(msg_id, _LSP_METHOD_NOT_FOUND,
379
+ f"unknown method: {method!r}")], [])