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.
- atomadic_forge/__init__.py +12 -0
- atomadic_forge/__main__.py +5 -0
- atomadic_forge/a0_qk_constants/__init__.py +1 -0
- atomadic_forge/a0_qk_constants/agent_plan_schema.py +120 -0
- atomadic_forge/a0_qk_constants/commandsmith_types.py +49 -0
- atomadic_forge/a0_qk_constants/config_defaults.py +38 -0
- atomadic_forge/a0_qk_constants/emergent_types.py +77 -0
- atomadic_forge/a0_qk_constants/error_codes.py +296 -0
- atomadic_forge/a0_qk_constants/forge_types.py +89 -0
- atomadic_forge/a0_qk_constants/gen_language.py +116 -0
- atomadic_forge/a0_qk_constants/lang_extensions.py +150 -0
- atomadic_forge/a0_qk_constants/policy_schema.py +48 -0
- atomadic_forge/a0_qk_constants/receipt_schema.py +311 -0
- atomadic_forge/a0_qk_constants/roi_constants.py +96 -0
- atomadic_forge/a0_qk_constants/semantic_types.py +61 -0
- atomadic_forge/a0_qk_constants/sidecar_schema.py +81 -0
- atomadic_forge/a0_qk_constants/synergy_types.py +62 -0
- atomadic_forge/a0_qk_constants/tier_names.py +47 -0
- atomadic_forge/a1_at_functions/__init__.py +1 -0
- atomadic_forge/a1_at_functions/agent_context_pack.py +193 -0
- atomadic_forge/a1_at_functions/agent_memory.py +139 -0
- atomadic_forge/a1_at_functions/agent_plan_emitter.py +324 -0
- atomadic_forge/a1_at_functions/agent_summary.py +277 -0
- atomadic_forge/a1_at_functions/body_extractor.py +306 -0
- atomadic_forge/a1_at_functions/card_renderer.py +210 -0
- atomadic_forge/a1_at_functions/certify_checks.py +445 -0
- atomadic_forge/a1_at_functions/chat_context.py +170 -0
- atomadic_forge/a1_at_functions/cherry_pick.py +71 -0
- atomadic_forge/a1_at_functions/classify_tier.py +115 -0
- atomadic_forge/a1_at_functions/commandsmith_discover.py +167 -0
- atomadic_forge/a1_at_functions/commandsmith_render.py +267 -0
- atomadic_forge/a1_at_functions/compiler_feedback.py +94 -0
- atomadic_forge/a1_at_functions/compliance_checker.py +228 -0
- atomadic_forge/a1_at_functions/config_io.py +68 -0
- atomadic_forge/a1_at_functions/cs1_renderer.py +588 -0
- atomadic_forge/a1_at_functions/doc_synthesizer.py +205 -0
- atomadic_forge/a1_at_functions/emergent_compose.py +192 -0
- atomadic_forge/a1_at_functions/emergent_rank.py +116 -0
- atomadic_forge/a1_at_functions/emergent_signature_extract.py +242 -0
- atomadic_forge/a1_at_functions/emergent_synthesize.py +88 -0
- atomadic_forge/a1_at_functions/enforce_planner.py +208 -0
- atomadic_forge/a1_at_functions/error_hints.py +105 -0
- atomadic_forge/a1_at_functions/evolution_log.py +94 -0
- atomadic_forge/a1_at_functions/forge_feedback.py +433 -0
- atomadic_forge/a1_at_functions/generation_quality.py +322 -0
- atomadic_forge/a1_at_functions/import_repair.py +211 -0
- atomadic_forge/a1_at_functions/import_smoke.py +102 -0
- atomadic_forge/a1_at_functions/js_parser.py +539 -0
- atomadic_forge/a1_at_functions/lineage_chain.py +144 -0
- atomadic_forge/a1_at_functions/lineage_reader.py +107 -0
- atomadic_forge/a1_at_functions/llm_client.py +554 -0
- atomadic_forge/a1_at_functions/local_signer.py +134 -0
- atomadic_forge/a1_at_functions/lsp_protocol.py +379 -0
- atomadic_forge/a1_at_functions/manifest_diff.py +314 -0
- atomadic_forge/a1_at_functions/mcp_protocol.py +1066 -0
- atomadic_forge/a1_at_functions/patch_scorer.py +267 -0
- atomadic_forge/a1_at_functions/plan_adapter.py +75 -0
- atomadic_forge/a1_at_functions/policy_loader.py +107 -0
- atomadic_forge/a1_at_functions/preflight_change.py +227 -0
- atomadic_forge/a1_at_functions/progress_reporter.py +81 -0
- atomadic_forge/a1_at_functions/provider_detect.py +157 -0
- atomadic_forge/a1_at_functions/provider_resolver.py +48 -0
- atomadic_forge/a1_at_functions/receipt_emitter.py +291 -0
- atomadic_forge/a1_at_functions/recipes.py +186 -0
- atomadic_forge/a1_at_functions/repo_explainer.py +124 -0
- atomadic_forge/a1_at_functions/roi_calculator.py +265 -0
- atomadic_forge/a1_at_functions/rollback_planner.py +147 -0
- atomadic_forge/a1_at_functions/sbom_emitter.py +155 -0
- atomadic_forge/a1_at_functions/scaffold_js.py +55 -0
- atomadic_forge/a1_at_functions/scaffold_pyproject.py +62 -0
- atomadic_forge/a1_at_functions/scaffold_starter.py +94 -0
- atomadic_forge/a1_at_functions/scout_walk.py +309 -0
- atomadic_forge/a1_at_functions/sidecar_parser.py +161 -0
- atomadic_forge/a1_at_functions/sidecar_validator.py +202 -0
- atomadic_forge/a1_at_functions/stub_detector.py +158 -0
- atomadic_forge/a1_at_functions/synergy_detect.py +166 -0
- atomadic_forge/a1_at_functions/synergy_render.py +252 -0
- atomadic_forge/a1_at_functions/synergy_surface_extract.py +163 -0
- atomadic_forge/a1_at_functions/test_runner.py +196 -0
- atomadic_forge/a1_at_functions/test_selector.py +122 -0
- atomadic_forge/a1_at_functions/tier_init_rebuild.py +122 -0
- atomadic_forge/a1_at_functions/tool_composer.py +130 -0
- atomadic_forge/a1_at_functions/transcript_log.py +70 -0
- atomadic_forge/a1_at_functions/wire_check.py +260 -0
- atomadic_forge/a2_mo_composites/__init__.py +1 -0
- atomadic_forge/a2_mo_composites/lineage_chain_store.py +122 -0
- atomadic_forge/a2_mo_composites/manifest_store.py +46 -0
- atomadic_forge/a2_mo_composites/plan_store.py +164 -0
- atomadic_forge/a2_mo_composites/receipt_signer.py +231 -0
- atomadic_forge/a3_og_features/__init__.py +1 -0
- atomadic_forge/a3_og_features/commandsmith_feature.py +267 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/__init__.py +3 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a0_qk_constants/__init__.py +4 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/src/mixed_pkg/a1_at_functions/__init__.py +14 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/conftest.py +10 -0
- atomadic_forge/a3_og_features/demo_packages/mixed_py_js/tests/test_mixed.py +18 -0
- atomadic_forge/a3_og_features/demo_runner.py +502 -0
- atomadic_forge/a3_og_features/emergent_feature.py +95 -0
- atomadic_forge/a3_og_features/emergent_pipeline_integration.py +154 -0
- atomadic_forge/a3_og_features/forge_enforce.py +107 -0
- atomadic_forge/a3_og_features/forge_evolve.py +176 -0
- atomadic_forge/a3_og_features/forge_loop.py +528 -0
- atomadic_forge/a3_og_features/forge_pipeline.py +295 -0
- atomadic_forge/a3_og_features/forge_plan_apply.py +222 -0
- atomadic_forge/a3_og_features/lsp_server.py +98 -0
- atomadic_forge/a3_og_features/mcp_server.py +160 -0
- atomadic_forge/a3_og_features/setup_wizard.py +337 -0
- atomadic_forge/a3_og_features/synergy_feature.py +65 -0
- atomadic_forge/a4_sy_orchestration/__init__.py +1 -0
- atomadic_forge/a4_sy_orchestration/cli.py +1284 -0
- atomadic_forge/commands/__init__.py +1 -0
- atomadic_forge/commands/_registry.py +36 -0
- atomadic_forge/commands/audit.py +142 -0
- atomadic_forge/commands/chat.py +133 -0
- atomadic_forge/commands/commandsmith.py +178 -0
- atomadic_forge/commands/config_cmd.py +145 -0
- atomadic_forge/commands/demo.py +142 -0
- atomadic_forge/commands/emergent.py +124 -0
- atomadic_forge/commands/emergent_then_synergy.py +70 -0
- atomadic_forge/commands/evolve.py +122 -0
- atomadic_forge/commands/evolve_then_iterate.py +70 -0
- atomadic_forge/commands/feature_then_emergent.py +111 -0
- atomadic_forge/commands/iterate.py +140 -0
- atomadic_forge/commands/synergy.py +96 -0
- atomadic_forge/commands/synergy_then_emergent.py +70 -0
- atomadic_forge-0.3.2.dist-info/METADATA +471 -0
- atomadic_forge-0.3.2.dist-info/RECORD +131 -0
- atomadic_forge-0.3.2.dist-info/WHEEL +5 -0
- atomadic_forge-0.3.2.dist-info/entry_points.txt +3 -0
- atomadic_forge-0.3.2.dist-info/licenses/LICENSE +15 -0
- 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}")], [])
|