codegraph-engine 2.3.0__py3-none-any.whl → 3.0.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.
Files changed (80) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/agent_brain.py +172 -2
  3. codegraph/agent_capabilities.py +320 -3
  4. codegraph/agent_rules.py +1 -1
  5. codegraph/api_drift.py +455 -0
  6. codegraph/async_queue/__init__.py +50 -0
  7. codegraph/async_queue/detectors/__init__.py +48 -0
  8. codegraph/async_queue/detectors/celery.py +160 -0
  9. codegraph/async_queue/detectors/kafka.py +168 -0
  10. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  11. codegraph/async_queue/detectors/redis.py +130 -0
  12. codegraph/async_queue/detectors/sqs.py +135 -0
  13. codegraph/async_queue/models.py +161 -0
  14. codegraph/async_queue/otel_models.py +74 -0
  15. codegraph/async_queue/otel_parser.py +337 -0
  16. codegraph/async_queue/reconciler.py +427 -0
  17. codegraph/async_queue/scanner.py +291 -0
  18. codegraph/async_queue/traversal.py +447 -0
  19. codegraph/change_impact.py +320 -110
  20. codegraph/cli.py +776 -25
  21. codegraph/cli_output.py +17 -2
  22. codegraph/context.py +38 -11
  23. codegraph/context_freshness.py +2 -0
  24. codegraph/database/__init__.py +14 -0
  25. codegraph/database/extractor.py +712 -17
  26. codegraph/database/interrogation.py +388 -2
  27. codegraph/database/schema_drift.py +862 -0
  28. codegraph/epistemic.py +7 -0
  29. codegraph/evidence_contract.py +21 -0
  30. codegraph/frameworks.py +171 -6
  31. codegraph/frameworks_nestjs.py +502 -0
  32. codegraph/frameworks_nextjs.py +337 -0
  33. codegraph/frameworks_react.py +457 -0
  34. codegraph/freshness.py +2 -0
  35. codegraph/frontend_html.py +109 -0
  36. codegraph/git.py +2 -0
  37. codegraph/git_state.py +2 -0
  38. codegraph/indexing/__init__.py +7 -1
  39. codegraph/indexing/indexer.py +291 -38
  40. codegraph/indexing/models.py +6 -0
  41. codegraph/indexing/parser.py +239 -0
  42. codegraph/indexing/pool.py +136 -0
  43. codegraph/indexing/scanner.py +6 -0
  44. codegraph/indexing/templates.py +418 -0
  45. codegraph/interrogation.py +128 -20
  46. codegraph/mcp/server.py +190 -28
  47. codegraph/models.py +7 -0
  48. codegraph/monorepo.py +3 -0
  49. codegraph/optimizer.py +42 -5
  50. codegraph/process_lifecycle.py +31 -13
  51. codegraph/refactor/__init__.py +36 -0
  52. codegraph/refactor/models.py +114 -0
  53. codegraph/refactor/renamer.py +844 -0
  54. codegraph/refactor/transactions.py +403 -0
  55. codegraph/resolver.py +1202 -159
  56. codegraph/retrieval_policy.py +6 -0
  57. codegraph/route_topology.py +172 -0
  58. codegraph/runtime/__init__.py +19 -0
  59. codegraph/runtime/collector.py +155 -0
  60. codegraph/runtime/db_watcher.py +191 -0
  61. codegraph/runtime/ingestor.py +30 -10
  62. codegraph/runtime/port_inspector.py +304 -0
  63. codegraph/runtime/runner.py +155 -6
  64. codegraph/runtime/trace_linker.py +175 -0
  65. codegraph/search/hybrid.py +26 -0
  66. codegraph/semantic_decorators.py +34 -3
  67. codegraph/structural_diff.py +2 -0
  68. codegraph/styles.py +144 -0
  69. codegraph/symbol_history.py +2 -0
  70. codegraph/tsconfig.py +236 -0
  71. codegraph/ui/__init__.py +6 -0
  72. codegraph/ui/server.py +715 -0
  73. codegraph/watcher.py +323 -0
  74. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/METADATA +150 -38
  75. codegraph_engine-3.0.0.dist-info/RECORD +129 -0
  76. codegraph_engine-2.3.0.dist-info/RECORD +0 -94
  77. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/WHEEL +0 -0
  78. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/entry_points.txt +0 -0
  79. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/licenses/LICENSE +0 -0
  80. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.0.dist-info}/top_level.txt +0 -0
codegraph/mcp/server.py CHANGED
@@ -1,10 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import functools
4
5
  import json
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
 
9
+ import anyio
10
+
8
11
  from codegraph.architecture import get_architecture as arch_get_architecture
9
12
  from codegraph.config import Settings
10
13
  from codegraph.context import _resolve_task_or_query_input
@@ -442,9 +445,9 @@ def create_server(
442
445
  task: dict[str, Any] | str = "",
443
446
  intent: str | None = None,
444
447
  max_tokens: int = 4000,
445
- max_files: int = 15,
448
+ max_files: int = 25,
446
449
  max_lines: int = 500,
447
- top_k: int = 15,
450
+ top_k: int = 25,
448
451
  plan: dict[str, Any] | None = None,
449
452
  mode: str = "BALANCED",
450
453
  explain: bool = False,
@@ -956,6 +959,58 @@ def create_server(
956
959
  with indexer.session() as con:
957
960
  return runtime_reconcile_static_runtime(con, indexer.repository, symbol=symbol, route=route, table=table)
958
961
 
962
+ def get_live_services() -> dict[str, Any]:
963
+ """Detect and attribute active localhost services, ports, PIDs, frameworks, and workspace sub-packages with zero idle CPU. Use when diagnosing running dev servers or multi-service architectures. Does not prove live HTTP response payloads or application layer health."""
964
+ from codegraph.runtime.port_inspector import (
965
+ discover_live_listening_ports,
966
+ discover_static_configured_ports,
967
+ )
968
+ live = [s.as_dict() for s in discover_live_listening_ports(indexer.repository)]
969
+ static = [s.as_dict() for s in discover_static_configured_ports(indexer.repository).values()]
970
+ return {"live_services": live, "configured_services": static, "count": len(live)}
971
+
972
+ def check_db_drift() -> dict[str, Any]:
973
+ """Detect database schema drift between migration files, live database state, and code ORM models with zero idle CPU. Flags unmapped tables, missing columns, and migration version state. Use when verifying whether database migrations are up to date with code models. Does not execute SQL data assertions or validate column constraints against live row data."""
974
+ from codegraph.runtime.db_watcher import detect_schema_drift
975
+ with indexer.session() as con:
976
+ return detect_schema_drift(con, indexer.repository).as_dict()
977
+
978
+ def get_distributed_trace(trace_id: str) -> dict[str, Any]:
979
+ """Stitch and reconstruct multi-service distributed execution trees across frontend, backend, and database boundaries for a specific W3C trace ID. Use when tracing cross-service requests or debugging multi-service latency bottlenecks. Does not capture unobserved code paths or untraced sidecar requests."""
980
+ from codegraph.runtime.trace_linker import link_distributed_trace
981
+ with indexer.session() as con:
982
+ res = link_distributed_trace(con, indexer.repository, trace_id)
983
+ return res.as_dict() if res else {"status": "not_found", "trace_id": trace_id}
984
+
985
+ def check_route_schema_drift(
986
+ route: str = "",
987
+ handler: str = "",
988
+ table: str | None = None,
989
+ schema: str | None = None,
990
+ ) -> dict[str, Any]:
991
+ """Detect schema validation drift between a route/handler input schema (Pydantic BaseModel, Django Form, DRF Serializer) and destination database table columns. Flags MISSING_REQUIRED_COLUMN, NULLABILITY_MISMATCH, TYPE_INCOMPATIBILITY, LENGTH_CONSTRAINT_DRIFT, and UNUSED_SCHEMA_FIELD. Does not execute live requests."""
992
+ from codegraph.database.schema_drift import detect_schema_drift as impl_detect_drift
993
+ target = route or handler
994
+ with indexer.session() as con:
995
+ return impl_detect_drift(
996
+ con,
997
+ indexer.repository,
998
+ route_or_handler=target,
999
+ target_table=table,
1000
+ schema_name=schema,
1001
+ )
1002
+
1003
+ def check_api_drift() -> dict[str, Any]:
1004
+ """Detect client-server API contract drift between frontend fetch or axios calls and backend route registrations. Flags orphaned client endpoints (404s), HTTP method mismatches (405s), and cross-language TypeScript-to-backend model drift. Use when validating frontend API calls against backend endpoints before deployment. Does not execute live HTTP requests or validate dynamic URL strings constructed via runtime interpolation."""
1005
+ from codegraph.api_drift import detect_api_contract_drift
1006
+ with indexer.session() as con:
1007
+ return detect_api_contract_drift(con, indexer.repository).as_dict()
1008
+
1009
+ def get_monorepo_packages() -> dict[str, Any]:
1010
+ """Catalog monorepo workspace packages, tools (pnpm, Turborepo, npm/yarn workspaces), entry points, and inter-package dependencies. Use when mapping multi-package repository structure and package boundaries. Does not prove build system execution order or runtime dependency loading."""
1011
+ from codegraph.monorepo import discover_workspace
1012
+ return discover_workspace(indexer.repository).as_dict()
1013
+
959
1014
  # -----------------------------------------------------------------------
960
1015
  # Git Intelligence Tools (5 Unified Capabilities)
961
1016
  # -----------------------------------------------------------------------
@@ -980,8 +1035,12 @@ def create_server(
980
1035
  def get_change_impact(base: str = "HEAD~1", head: str = "HEAD", max_depth: int = 2, max_results: int = 50) -> dict[str, Any]:
981
1036
  """Compute deep downstream change impact across callers, callees, framework routes, covering tests, mutating database queries, and monorepo packages. Use when analyzing blast radius and ripple effects of git commits. Does not assume unobserved runtime paths are impossible."""
982
1037
  from codegraph.change_impact import get_deep_change_impact
1038
+ target_sym = None
1039
+ clean_base = base.strip()
1040
+ if clean_base.startswith("symbol:"):
1041
+ target_sym = clean_base.split("symbol:", 1)[1]
983
1042
  with indexer.session() as con:
984
- return get_deep_change_impact(indexer.repository, con, base=base, head=head, max_depth=max_depth, max_results=max_results).as_dict()
1043
+ return get_deep_change_impact(indexer.repository, con, base=base, head=head, max_depth=max_depth, max_results=max_results, symbol=target_sym).as_dict()
985
1044
 
986
1045
  def check_context_freshness(task: str = "", context_packet: dict[str, Any] | None = None) -> dict[str, Any]:
987
1046
  """Validate whether a previously compiled context packet or task context is still VALID, PARTIALLY_STALE, or STALE. Use when checking if cached context is valid after edits. Does not invalidate context when only unrelated files change."""
@@ -1015,6 +1074,49 @@ def create_server(
1015
1074
  with indexer.session() as con:
1016
1075
  return impl_detect_semantic_conflicts(indexer.repository, base_branch=base_branch, head_branch=head_branch, con=con).as_dict()
1017
1076
 
1077
+ def safe_rename(
1078
+ target: str,
1079
+ new_name: str,
1080
+ dry_run: bool = True,
1081
+ force_uncertain: bool = False,
1082
+ ) -> dict[str, Any]:
1083
+ """Perform a deterministic, syntax-validated, transaction-safe AST rename of a symbol across the repository.
1084
+ Follows the core invariant: DISCOVER -> PLAN -> PREVIEW -> VALIDATE -> COMMIT -> REINDEX -> VERIFY.
1085
+ When dry_run=True, performs zero disk mutation and returns preview diffs, risk assessment, affected tests, and affected routes.
1086
+ When dry_run=False, validates all preconditions and syntax in memory, commits atomically using sibling temp files and os.replace, persists a rollback manifest, and triggers an incremental index update.
1087
+ If risk != LOW (due to UNKNOWN/POSSIBLE/AMBIGUOUS references), the rename is BLOCKED unless force_uncertain=True."""
1088
+ from codegraph.refactor.renamer import execute_safe_rename
1089
+
1090
+ with indexer.session() as con:
1091
+ result = execute_safe_rename(
1092
+ repository=indexer.repository,
1093
+ con=con,
1094
+ target=target,
1095
+ new_name=new_name,
1096
+ dry_run=dry_run,
1097
+ force_uncertain=force_uncertain,
1098
+ )
1099
+ return result.to_dict()
1100
+
1101
+ def rollback_refactor(
1102
+ transaction_id: str,
1103
+ ) -> dict[str, Any]:
1104
+ """Atomically rollback an applied refactoring transaction using its rollback manifest ID.
1105
+ Verifies that files on disk have not been modified externally since the refactor before restoring original contents."""
1106
+ from codegraph.refactor.models import RefactorStatus
1107
+ from codegraph.refactor.transactions import rollback_refactor as do_rollback
1108
+
1109
+ result = do_rollback(
1110
+ repository=indexer.repository,
1111
+ transaction_id=transaction_id,
1112
+ )
1113
+ if result.status == RefactorStatus.ROLLED_BACK:
1114
+ try:
1115
+ indexer.index()
1116
+ except Exception:
1117
+ pass
1118
+ return result.to_dict()
1119
+
1018
1120
  core_tools: dict[str, Any] = {
1019
1121
  "resolve_symbol": resolve_symbol,
1020
1122
  "search_symbols": search_symbols,
@@ -1040,10 +1142,16 @@ def create_server(
1040
1142
  "detect_semantic_conflicts": detect_semantic_conflicts,
1041
1143
  }
1042
1144
 
1145
+ refactor_tools: dict[str, Any] = {
1146
+ "safe_rename": safe_rename,
1147
+ "rollback_refactor": rollback_refactor,
1148
+ }
1149
+
1043
1150
  # Map of all available tools
1044
1151
  all_tools: dict[str, Any] = {
1045
1152
  **core_tools,
1046
1153
  **git_intelligence_tools,
1154
+ **refactor_tools,
1047
1155
  "search_code": search_code,
1048
1156
  "read_file": read_file,
1049
1157
  "find_symbol": find_symbol,
@@ -1087,6 +1195,12 @@ def create_server(
1087
1195
  "ingest_runtime_traces": ingest_runtime_traces,
1088
1196
  "get_runtime_trace": get_runtime_trace,
1089
1197
  "reconcile_static_runtime": reconcile_static_runtime,
1198
+ "get_live_services": get_live_services,
1199
+ "check_db_drift": check_db_drift,
1200
+ "check_route_schema_drift": check_route_schema_drift,
1201
+ "get_distributed_trace": get_distributed_trace,
1202
+ "check_api_drift": check_api_drift,
1203
+ "get_monorepo_packages": get_monorepo_packages,
1090
1204
  }
1091
1205
 
1092
1206
  agent_names = {
@@ -1179,6 +1293,7 @@ def create_server(
1179
1293
 
1180
1294
  get_graph_cache().clear()
1181
1295
  get_parse_cache().clear()
1296
+ indexer.reset_pool()
1182
1297
  return True
1183
1298
  return False
1184
1299
 
@@ -1196,6 +1311,40 @@ def create_server(
1196
1311
 
1197
1312
  app.tool(name=tool_name)(_make_guarded(target_fn))
1198
1313
 
1314
+ # FastMCP Event Loop Offload:
1315
+ # Ensure synchronous tools execute in worker threads so MCP asyncio loop is never blocked
1316
+ orig_call_tool = app._tool_manager.call_tool
1317
+
1318
+ async def _concurrent_call_tool(
1319
+ name: str,
1320
+ arguments: dict[str, Any],
1321
+ context: Any = None,
1322
+ convert_result: bool = False,
1323
+ ) -> Any:
1324
+ tool = app._tool_manager.get_tool(name)
1325
+ if tool is not None and not tool.is_async:
1326
+ def _invoke_in_thread() -> Any:
1327
+ return asyncio.run(
1328
+ orig_call_tool(
1329
+ name,
1330
+ arguments,
1331
+ context=context,
1332
+ convert_result=convert_result,
1333
+ )
1334
+ )
1335
+
1336
+ return await anyio.to_thread.run_sync(_invoke_in_thread)
1337
+
1338
+ return await orig_call_tool(
1339
+ name,
1340
+ arguments,
1341
+ context=context,
1342
+ convert_result=convert_result,
1343
+ )
1344
+
1345
+ app._tool_manager.call_tool = _concurrent_call_tool # type: ignore[method-assign]
1346
+
1347
+
1199
1348
  # -----------------------------------------------------------------------
1200
1349
  # MCP Resources
1201
1350
  # -----------------------------------------------------------------------
@@ -1208,47 +1357,60 @@ def create_server(
1208
1357
  return json.dumps(get_capability_manifest(), indent=2)
1209
1358
 
1210
1359
  @app.resource("codebase://status")
1211
- def resource_status() -> str:
1360
+ async def resource_status() -> str:
1212
1361
  """Repository index status, generation, and file counts."""
1213
- with indexer.session() as con:
1214
- report = check_freshness(indexer.repository, con)
1215
- return json.dumps(
1216
- {
1217
- "repository": str(indexer.repository),
1218
- "generation": report.index_generation,
1219
- "freshness": report.status.value,
1220
- "files": con.execute("SELECT count(*) FROM files").fetchone()[0],
1221
- "symbols": con.execute("SELECT count(*) FROM symbols").fetchone()[0],
1222
- },
1223
- indent=2,
1224
- )
1362
+ def _get_status() -> str:
1363
+ with indexer.session() as con:
1364
+ report = check_freshness(indexer.repository, con)
1365
+ return json.dumps(
1366
+ {
1367
+ "repository": str(indexer.repository),
1368
+ "generation": report.index_generation,
1369
+ "freshness": report.status.value,
1370
+ "files": con.execute("SELECT count(*) FROM files").fetchone()[0],
1371
+ "symbols": con.execute("SELECT count(*) FROM symbols").fetchone()[0],
1372
+ },
1373
+ indent=2,
1374
+ )
1375
+
1376
+ return await anyio.to_thread.run_sync(_get_status)
1225
1377
 
1226
1378
  @app.resource("codebase://architecture")
1227
- def resource_architecture() -> str:
1379
+ async def resource_architecture() -> str:
1228
1380
  """High-level architectural overview."""
1229
- with indexer.session() as con:
1230
- arch = arch_get_architecture(con, indexer.repository)
1231
- return json.dumps(arch, indent=2)
1381
+ def _get_arch() -> str:
1382
+ with indexer.session() as con:
1383
+ arch = arch_get_architecture(con, indexer.repository)
1384
+ return json.dumps(arch, indent=2)
1385
+
1386
+ return await anyio.to_thread.run_sync(_get_arch)
1232
1387
 
1233
1388
  @app.resource("codebase://modules")
1234
- def resource_modules() -> str:
1389
+ async def resource_modules() -> str:
1235
1390
  """List of indexed repository module paths."""
1236
- with indexer.session() as con:
1237
- rows = con.execute("SELECT path FROM files ORDER BY path LIMIT 500").fetchall()
1238
- return "\n".join(r[0] for r in rows)
1391
+ def _get_modules() -> str:
1392
+ with indexer.session() as con:
1393
+ rows = con.execute("SELECT path FROM files ORDER BY path LIMIT 500").fetchall()
1394
+ return "\n".join(r[0] for r in rows)
1395
+
1396
+ return await anyio.to_thread.run_sync(_get_modules)
1239
1397
 
1240
1398
  @app.resource("codebase://health")
1241
- def resource_health() -> str:
1399
+ async def resource_health() -> str:
1242
1400
  """Database health and integrity report."""
1243
- with indexer.session() as con:
1244
- health = check_database_health(con)
1245
- return json.dumps(health, indent=2)
1401
+ def _get_health() -> str:
1402
+ with indexer.session() as con:
1403
+ health = check_database_health(con)
1404
+ return json.dumps(health, indent=2)
1405
+
1406
+ return await anyio.to_thread.run_sync(_get_health)
1246
1407
 
1247
1408
  @app.resource("codebase://resources")
1248
1409
  def resource_resources() -> str:
1249
1410
  """Resource governor state, memory bounds, pressure, and activity mode."""
1250
1411
  return json.dumps(indexer.governor.get_state().as_dict(), indent=2)
1251
1412
 
1413
+
1252
1414
  # -----------------------------------------------------------------------
1253
1415
  # MCP Prompts
1254
1416
  # -----------------------------------------------------------------------
codegraph/models.py CHANGED
@@ -56,6 +56,13 @@ EdgeType = Literal[
56
56
  "EVENT_LISTENER",
57
57
  "TASK_HANDLER",
58
58
  "COMMAND_HANDLER",
59
+ "DISPATCHES_TASK",
60
+ "TRIGGERS_SIGNAL",
61
+ "HANDLES_SIGNAL",
62
+ "RENDERS_TEMPLATE",
63
+ "DISPATCHES_FORWARD",
64
+ "TOOL_HANDLER",
65
+ "PIPELINE_STEP",
59
66
  "TESTS",
60
67
  "TESTS_ROUTE",
61
68
  "TESTS_SYMBOL",
codegraph/monorepo.py CHANGED
@@ -920,3 +920,6 @@ def detect_workspace(
920
920
  unresolved_dependencies=unresolved_dependencies,
921
921
  ambiguous_packages=ambiguous_packages,
922
922
  )
923
+
924
+
925
+ discover_workspace = detect_workspace
codegraph/optimizer.py CHANGED
@@ -41,7 +41,7 @@ _CONFIDENCE_RANK = {
41
41
 
42
42
  # Hard upper safety limits enforced even when omitted or exceeded
43
43
  HARD_MAX_TOKENS = 20_000
44
- HARD_MAX_FILES = 30
44
+ HARD_MAX_FILES = 40
45
45
  HARD_MAX_LINES = 1_500
46
46
  HARD_MAX_SYMBOLS = 50
47
47
  HARD_MAX_RELATIONSHIPS = 60
@@ -54,7 +54,7 @@ class ContextBudgetSpec:
54
54
  max_tokens: int = 20_000
55
55
  max_symbols: int = 25
56
56
  max_relationships: int = 30
57
- max_files: int = 15
57
+ max_files: int = 25
58
58
  max_lines: int = 500
59
59
  max_depth: int = 3
60
60
  max_package_depth: int = 2
@@ -94,7 +94,7 @@ class ContextBudget:
94
94
  tool_calls: int = 1
95
95
  max_symbols: int = 25
96
96
  max_relationships: int = 30
97
- max_files: int = 15
97
+ max_files: int = 25
98
98
  max_lines: int = 500
99
99
  max_depth: int = 3
100
100
  max_package_depth: int = 2
@@ -374,6 +374,17 @@ def compute_item_utility(
374
374
  dim_unseen = selected_dimensions is not None and dim not in selected_dimensions and dim != "GENERAL"
375
375
  coverage_factor = 1.3 if (layer_unseen or dim_unseen) else 1.0
376
376
 
377
+ # Priority boost for task-critical items over tangential ones
378
+ priority_dim_boost = 0.0
379
+ if dim == "TARGET" or item.relevance_score >= 0.80:
380
+ priority_dim_boost = 0.20
381
+ elif dim in ("ROUTE", "CALLER"):
382
+ priority_dim_boost = 0.12
383
+ elif dim == "CALLEE":
384
+ priority_dim_boost = 0.08
385
+ elif dim in ("TEST", "GIT", "PACKAGE", "GENERAL") and item.relevance_score < 0.50:
386
+ priority_dim_boost = -0.08
387
+
377
388
  # Redundancy penalty: heavily discount items sharing duplicate keys with already selected items
378
389
  redundancy_penalty = 0.5 if item.duplicate_key in selected_duplicate_keys else 0.0
379
390
 
@@ -384,6 +395,7 @@ def compute_item_utility(
384
395
  + (fresh_factor * 0.15)
385
396
  + (epistemic_factor * 0.10)
386
397
  + pkg_adj
398
+ + priority_dim_boost
387
399
  ) * coverage_factor
388
400
 
389
401
  final_score = max(0.01, utility - redundancy_penalty - conf_penalty - art_penalty - dist_penalty)
@@ -572,7 +584,7 @@ def optimize_context_budget(
572
584
  """Deterministically select the highest-utility, coverage-preserving subset of candidates."""
573
585
  raw_max_symbols = budget_spec.max_symbols if budget_spec else 25
574
586
  raw_max_relationships = budget_spec.max_relationships if budget_spec else 30
575
- raw_max_files = budget_spec.max_files if budget_spec else 15
587
+ raw_max_files = budget_spec.max_files if budget_spec else 25
576
588
  raw_max_lines = budget_spec.max_lines if budget_spec else 500
577
589
  max_depth = budget_spec.max_depth if budget_spec else 3
578
590
  max_package_depth = budget_spec.max_package_depth if budget_spec else 2
@@ -767,7 +779,32 @@ def optimize_context_budget(
767
779
  )
768
780
  break
769
781
 
770
- # Pass 1b: Architectural Layer Coverage pass — ensure distinct layers
782
+ # Pass 1b: Task-Critical Candidates Priority pass — ensure all direct targets,
783
+ # routes, and direct callers/callees are admitted into the file budget before
784
+ # tangential layer coverage files consume file slots.
785
+ for item in sorted_candidates:
786
+ if item in selected or _is_excluded(item, task_spec):
787
+ continue
788
+ if item.duplicate_key in selected_duplicate_keys:
789
+ continue
790
+ dim = item.infer_dimension()
791
+ is_task_critical = (
792
+ dim in ("TARGET", "ROUTE", "CALLER")
793
+ or item.relevance_score >= 0.70
794
+ or (dim == "CALLEE" and item.relevance_score >= 0.50)
795
+ )
796
+ if is_task_critical:
797
+ if _can_fit(item) or (dim == "TARGET" and len(selected) == 0 and _trim_to_fit(item)):
798
+ _accept(
799
+ item,
800
+ is_coverage_fill=False,
801
+ why=[
802
+ f"Task-critical priority candidate: dimension '{dim}'",
803
+ f"Relevance: {item.relevance_score:.2f}, Evidence: {item.evidence_quality:.2f}",
804
+ ],
805
+ )
806
+
807
+ # Pass 1c: Architectural Layer Coverage pass — ensure distinct layers
771
808
  # (e.g. ENTRYPOINT, SERVICE, DATA, TEST, GIT) get represented if candidates exist
772
809
  for item in sorted_candidates:
773
810
  if item in selected or _is_excluded(item, task_spec):
@@ -541,16 +541,28 @@ def is_parent_alive(
541
541
  ) -> bool:
542
542
  """Return True if the parent process `parent_pid` is still alive and owns this child."""
543
543
  if parent_pid <= 1:
544
- return False
544
+ # PID 0 and 1 represent init / systemd / launchd / container root; always considered alive
545
+ return True
545
546
 
546
- if sys.platform != "win32":
547
- actual_ppid = current_ppid if current_ppid is not None else os.getppid()
548
- if actual_ppid != parent_pid:
549
- # Reparented to PID 1 / launchd / systemd --user
550
- return False
547
+ # 1. Direct probe whether parent PID exists via os.kill(pid, 0)
548
+ try:
549
+ os.kill(parent_pid, 0)
550
+ except ProcessLookupError:
551
+ # ESRCH: Process does not exist; parent is definitely dead
552
+ return False
553
+ except PermissionError:
554
+ # EPERM: Process exists, but caller lacks permissions to signal it (e.g. sandbox/different user)
555
+ return True
556
+ except OSError:
557
+ # Other OS error: safe fallback is alive
558
+ return True
551
559
 
552
560
  live = get_live_process_info(parent_pid)
553
- if live is None or live.is_zombie:
561
+ if live is None:
562
+ # If OS inspection was restricted (e.g. sandbox blocking proc_pidinfo),
563
+ # but os.kill succeeded above, the process is alive.
564
+ return True
565
+ if live.is_zombie:
554
566
  return False
555
567
 
556
568
  if parent_create_token and live.create_token and live.create_token != parent_create_token:
@@ -964,16 +976,22 @@ class MCPLifecycleController:
964
976
 
965
977
  def _watchdog_loop(self) -> None:
966
978
  """Low-overhead event-backed watchdog detecting parent termination or closed stdin."""
979
+ disable_parent_watchdog = (
980
+ os.environ.get("CODEGRAPH_DISABLE_PARENT_WATCHDOG", "").strip().lower()
981
+ in ("1", "true", "yes")
982
+ or self.record.parent_pid <= 1
983
+ )
967
984
  while not self._stop_event.wait(timeout=self.poll_interval_sec):
968
985
  if _is_stream_closed_or_eof(sys.stdin):
969
986
  self.shutdown("stdin_closed")
970
987
  return
971
- if not is_parent_alive(
972
- self.record.parent_pid,
973
- self.record.parent_create_token,
974
- ):
975
- self.shutdown("parent_process_terminated")
976
- return
988
+ if not disable_parent_watchdog:
989
+ if not is_parent_alive(
990
+ self.record.parent_pid,
991
+ self.record.parent_create_token,
992
+ ):
993
+ self.shutdown("parent_process_terminated")
994
+ return
977
995
 
978
996
  def _atexit_cleanup(self) -> None:
979
997
  self.shutdown("atexit")
@@ -0,0 +1,36 @@
1
+ """Deterministic, syntax-validated, transaction-safe refactoring engine for CodeGraph MCP."""
2
+ from __future__ import annotations
3
+
4
+ from codegraph.refactor.models import (
5
+ RefactorPlan,
6
+ RefactorResult,
7
+ RefactorRisk,
8
+ RefactorStatus,
9
+ TokenReplacementSpan,
10
+ )
11
+ from codegraph.refactor.renamer import (
12
+ execute_safe_rename,
13
+ plan_safe_rename,
14
+ preview_safe_rename,
15
+ )
16
+ from codegraph.refactor.transactions import (
17
+ RefactorLock,
18
+ RefactorLockError,
19
+ commit_refactor_transaction,
20
+ rollback_refactor,
21
+ )
22
+
23
+ __all__ = [
24
+ "RefactorLock",
25
+ "RefactorLockError",
26
+ "RefactorPlan",
27
+ "RefactorResult",
28
+ "RefactorRisk",
29
+ "RefactorStatus",
30
+ "TokenReplacementSpan",
31
+ "commit_refactor_transaction",
32
+ "execute_safe_rename",
33
+ "plan_safe_rename",
34
+ "preview_safe_rename",
35
+ "rollback_refactor",
36
+ ]
@@ -0,0 +1,114 @@
1
+ """Models and data structures for deterministic, syntax-validated, transaction-safe refactoring."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import asdict, dataclass, field
5
+ from enum import StrEnum
6
+ from typing import Any
7
+
8
+
9
+ class RefactorStatus(StrEnum):
10
+ """Execution status of a refactoring plan or transaction."""
11
+
12
+ READY = "READY"
13
+ BLOCKED = "BLOCKED"
14
+ APPLIED = "APPLIED"
15
+ ROLLED_BACK = "ROLLED_BACK"
16
+ ERROR = "ERROR"
17
+
18
+
19
+ class RefactorRisk(StrEnum):
20
+ """Assessed risk of an AST refactoring operation."""
21
+
22
+ LOW = "LOW" # All affected relationships are FACT / deterministically verified.
23
+ MEDIUM = "MEDIUM" # POSSIBLE relationships exist but scope is bounded.
24
+ HIGH = "HIGH" # UNKNOWN / AMBIGUOUS / CONFLICT relationships could make rename incomplete.
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class TokenReplacementSpan:
29
+ """Exact byte/token coordinates for an AST-grounded replacement."""
30
+
31
+ path: str
32
+ start_line: int
33
+ start_col: int
34
+ end_line: int
35
+ end_col: int
36
+ old_token: str
37
+ new_token: str
38
+ evidence: str
39
+ symbol_id: str
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ return asdict(self)
43
+
44
+
45
+ @dataclass
46
+ class RefactorPlan:
47
+ """Deterministic refactoring plan before in-memory preview or commit."""
48
+
49
+ transaction_id: str
50
+ target_symbol: str
51
+ new_name: str
52
+ files: list[str] = field(default_factory=list)
53
+ spans: list[TokenReplacementSpan] = field(default_factory=list)
54
+ risk: RefactorRisk = RefactorRisk.LOW
55
+ uncertainty_reasons: list[str] = field(default_factory=list)
56
+ affected_tests: list[str] = field(default_factory=list)
57
+ affected_routes: list[str] = field(default_factory=list)
58
+ affected_db_relationships: list[str] = field(default_factory=list)
59
+ original_file_hashes: dict[str, str] = field(default_factory=dict)
60
+ original_file_sizes: dict[str, int] = field(default_factory=dict)
61
+ original_file_mtimes: dict[str, float] = field(default_factory=dict)
62
+
63
+ def to_dict(self) -> dict[str, Any]:
64
+ return {
65
+ "transaction_id": self.transaction_id,
66
+ "target_symbol": self.target_symbol,
67
+ "new_name": self.new_name,
68
+ "files": sorted(self.files),
69
+ "spans": [s.to_dict() for s in sorted(self.spans, key=lambda x: (x.path, x.start_line, x.start_col))],
70
+ "risk": str(self.risk),
71
+ "uncertainty_reasons": sorted(self.uncertainty_reasons),
72
+ "affected_tests": sorted(self.affected_tests),
73
+ "affected_routes": sorted(self.affected_routes),
74
+ "affected_db_relationships": sorted(self.affected_db_relationships),
75
+ "original_file_hashes": dict(sorted(self.original_file_hashes.items())),
76
+ }
77
+
78
+
79
+ @dataclass
80
+ class RefactorResult:
81
+ """Complete result of a refactor preview, apply, or rollback."""
82
+
83
+ status: RefactorStatus
84
+ target_symbol: str
85
+ new_name: str
86
+ risk: RefactorRisk
87
+ uncertainty_reasons: list[str] = field(default_factory=list)
88
+ files_changed: int = 0
89
+ symbols_changed: int = 0
90
+ spans_count: int = 0
91
+ tests_affected: list[str] = field(default_factory=list)
92
+ routes_affected: list[str] = field(default_factory=list)
93
+ db_affected: list[str] = field(default_factory=list)
94
+ rollback_id: str | None = None
95
+ diffs: dict[str, str] = field(default_factory=dict)
96
+ errors: list[str] = field(default_factory=list)
97
+
98
+ def to_dict(self) -> dict[str, Any]:
99
+ return {
100
+ "status": str(self.status),
101
+ "target_symbol": self.target_symbol,
102
+ "new_name": self.new_name,
103
+ "risk": str(self.risk),
104
+ "uncertainty_reasons": sorted(self.uncertainty_reasons),
105
+ "files_changed": self.files_changed,
106
+ "symbols_changed": self.symbols_changed,
107
+ "spans_count": self.spans_count,
108
+ "tests_affected": sorted(self.tests_affected),
109
+ "routes_affected": sorted(self.routes_affected),
110
+ "db_affected": sorted(self.db_affected),
111
+ "rollback_id": self.rollback_id,
112
+ "diffs": dict(sorted(self.diffs.items())),
113
+ "errors": sorted(self.errors),
114
+ }