codegraph-engine 2.1.7__py3-none-any.whl → 2.2.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.
codegraph/context.py CHANGED
@@ -594,6 +594,7 @@ def _get_context_impl(
594
594
  if path:
595
595
  c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
596
596
  c.row_factory = sqlite3.Row
597
+ c.execute("PRAGMA busy_timeout = 15000")
597
598
  try:
598
599
  for query in queries:
599
600
  for item in search(
@@ -629,6 +630,7 @@ def _get_context_impl(
629
630
  if path:
630
631
  c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
631
632
  c.row_factory = sqlite3.Row
633
+ c.execute("PRAGMA busy_timeout = 15000")
632
634
  try:
633
635
  return c.execute(
634
636
  "SELECT route_path, http_method, handler_name, file_path, line, endpoint_id FROM framework_routes"
@@ -665,6 +667,7 @@ def _get_context_impl(
665
667
  if path:
666
668
  c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
667
669
  c.row_factory = sqlite3.Row
670
+ c.execute("PRAGMA busy_timeout = 15000")
668
671
  try:
669
672
  archs.append(get_architecture(c, repo))
670
673
  finally:
@@ -708,6 +711,9 @@ def _get_context_impl(
708
711
 
709
712
  stage1_candidate_count = len(candidate_dicts)
710
713
 
714
+ # Instantiate ConstraintGuard for hard safety invariants
715
+ _guard = ConstraintGuard.from_task_spec(task_spec)
716
+
711
717
  # Process framework routes
712
718
  route_relationships: list[Relationship] = []
713
719
  entry_points_out: list[dict[str, object]] = []
@@ -717,16 +723,26 @@ def _get_context_impl(
717
723
  r_path = r["route_path"]
718
724
  r_method = r["http_method"]
719
725
  ep_id = r["endpoint_id"] or f"{r_method} {r_path}"
726
+ r_segments = [
727
+ seg.lower()
728
+ for seg in str(r_path).split("/")
729
+ if len(seg) >= 4
730
+ and not seg.startswith("{")
731
+ and not seg.startswith("<")
732
+ and seg.lower() not in {"api", "v1", "v2"}
733
+ ]
720
734
  matches_task = (
721
735
  r_path in task_display_str
722
736
  or ep_id.lower() in task_display_str.lower()
723
737
  or (r_method in task_display_str.upper() and r_path in task_display_str)
738
+ or task_spec.intent == "ARCHITECTURE"
739
+ or any(seg in task_display_str.lower() for seg in r_segments)
724
740
  or any(
725
741
  (len(tgt) >= 3 and (tgt.lower() == r["handler_name"].lower() or tgt.lower() in r_path.lower()))
726
742
  for tgt in task_spec.targets
727
743
  )
728
744
  )
729
- if matches_task:
745
+ if matches_task and _guard.allows_symbol(str(r["handler_name"])) and _guard.allows_file(str(r["file_path"])):
730
746
  target_symbols.add(r["handler_name"])
731
747
  r_line = r["line"] or 1
732
748
  candidate_dicts.append(
@@ -799,8 +815,108 @@ def _get_context_impl(
799
815
  except Exception:
800
816
  pass
801
817
 
802
- # Instantiate ConstraintGuard for hard safety invariants
803
- _guard = ConstraintGuard.from_task_spec(task_spec)
818
+ # Seed architectural boundary classes and gateway methods for ARCHITECTURE intent
819
+ if task_spec.intent == "ARCHITECTURE":
820
+ try:
821
+ arch_rows = con.execute(
822
+ "SELECT canonical_id, qualified_name, name, kind, path, start_line, end_line "
823
+ "FROM symbols WHERE kind = 'class' OR name = 'app'"
824
+ ).fetchall()
825
+ for a_row in arch_rows:
826
+ a_name = str(a_row["name"])
827
+ a_sym = str(a_row["qualified_name"] or a_name)
828
+ a_cid = str(a_row["canonical_id"] or "")
829
+ a_file = str(a_row["path"] or "")
830
+ if (
831
+ _guard.allows_symbol(a_sym)
832
+ and _guard.allows_symbol(a_name)
833
+ and (not a_cid or _guard.allows_canonical_id(a_cid))
834
+ and _guard.allows_file(a_file)
835
+ ):
836
+ target_symbols.add(a_name)
837
+ target_symbols.add(a_sym)
838
+ candidate_dicts.append(
839
+ {
840
+ "file": a_file,
841
+ "symbol": a_name,
842
+ "canonical_id": a_cid,
843
+ "start_line": int(a_row["start_line"] or 1),
844
+ "end_line": int(a_row["end_line"] or 1),
845
+ "score": 0.90,
846
+ "relationship": "DEFINES",
847
+ "snippet": f"Architectural symbol {a_sym} ({a_row['kind']}) in {a_file}",
848
+ }
849
+ )
850
+ if "gateway" in task_display_str.lower():
851
+ gw_rows = con.execute(
852
+ "SELECT canonical_id, qualified_name, name, kind, path, start_line, end_line "
853
+ "FROM symbols WHERE qualified_name LIKE '%Gateway.%'"
854
+ ).fetchall()
855
+ for g_row in gw_rows:
856
+ g_name = str(g_row["name"])
857
+ g_sym = str(g_row["qualified_name"] or g_name)
858
+ g_cid = str(g_row["canonical_id"] or "")
859
+ g_file = str(g_row["path"] or "")
860
+ if (
861
+ _guard.allows_symbol(g_sym)
862
+ and _guard.allows_symbol(g_name)
863
+ and (not g_cid or _guard.allows_canonical_id(g_cid))
864
+ and _guard.allows_file(g_file)
865
+ ):
866
+ target_symbols.add(g_name)
867
+ target_symbols.add(g_sym)
868
+ candidate_dicts.append(
869
+ {
870
+ "file": g_file,
871
+ "symbol": g_name,
872
+ "canonical_id": g_cid,
873
+ "start_line": int(g_row["start_line"] or 1),
874
+ "end_line": int(g_row["end_line"] or 1),
875
+ "score": 0.88,
876
+ "relationship": "DEFINES",
877
+ "snippet": f"Gateway method {g_sym} in {g_file}",
878
+ }
879
+ )
880
+ except Exception:
881
+ pass
882
+
883
+ # Seed direct methods of resolved class targets
884
+ for res in _target_resolutions.values():
885
+ cls_qname = res.qualified_name
886
+ if cls_qname and res.target_type.value == "CLASS":
887
+ try:
888
+ m_rows = con.execute(
889
+ "SELECT canonical_id, qualified_name, name, kind, path, start_line, end_line "
890
+ "FROM symbols WHERE qualified_name LIKE ?",
891
+ (f"{cls_qname}.%",),
892
+ ).fetchall()
893
+ for m_row in m_rows:
894
+ m_name = str(m_row["name"])
895
+ m_sym = str(m_row["qualified_name"] or m_name)
896
+ m_cid = str(m_row["canonical_id"] or "")
897
+ m_file = str(m_row["path"] or "")
898
+ if (
899
+ _guard.allows_symbol(m_sym)
900
+ and _guard.allows_symbol(m_name)
901
+ and (not m_cid or _guard.allows_canonical_id(m_cid))
902
+ and _guard.allows_file(m_file)
903
+ ):
904
+ target_symbols.add(m_name)
905
+ target_symbols.add(m_sym)
906
+ candidate_dicts.append(
907
+ {
908
+ "file": m_file,
909
+ "symbol": m_name,
910
+ "canonical_id": m_cid,
911
+ "start_line": int(m_row["start_line"] or 1),
912
+ "end_line": int(m_row["end_line"] or 1),
913
+ "score": 0.88,
914
+ "relationship": "DEFINES",
915
+ "snippet": f"Method {m_sym} of target class {cls_qname}",
916
+ }
917
+ )
918
+ except Exception:
919
+ pass
804
920
 
805
921
  # 5. Graph exploration (callers/callees) & Tests with deterministic seeds
806
922
  t_graph_start = time.perf_counter()
@@ -748,6 +748,8 @@ def get_db_schema(
748
748
  schema: str | None = None,
749
749
  *,
750
750
  dialect: str | None = None,
751
+ limit: int | None = None,
752
+ offset: int = 0,
751
753
  ) -> dict[str, Any]:
752
754
  """Return the complete repository database schema overview with safe credential redaction."""
753
755
  if isinstance(repo_or_dialect, str) and not dialect:
@@ -800,21 +802,42 @@ def get_db_schema(
800
802
 
801
803
  dialects = sorted({t["dialect"] for t in tables if t.get("dialect")}) or ["UNKNOWN"]
802
804
 
803
- return redact_payload({
805
+ safe_tables = tables
806
+ safe_models = models
807
+ safe_migrations = migrations
808
+ safe_cols = columns
809
+ safe_rels = rels
810
+
811
+ if limit is not None:
812
+ safe_offset = max(0, offset)
813
+ safe_limit = max(0, limit)
814
+ safe_tables = tables[safe_offset : safe_offset + safe_limit]
815
+ safe_models = models[safe_offset : safe_offset + safe_limit]
816
+ safe_migrations = migrations[safe_offset : safe_offset + safe_limit]
817
+ safe_cols = columns[: max(safe_limit * 5, 20)]
818
+ safe_rels = rels[: max(safe_limit * 5, 20)]
819
+
820
+ result_dict: dict[str, Any] = {
804
821
  "status": "ok",
805
822
  "dialects": dialects,
806
823
  "table_count": len(tables),
807
824
  "column_count": len(columns),
808
825
  "model_count": len(models),
809
826
  "migration_count": len(migrations),
810
- "tables": tables,
811
- "columns": columns,
812
- "models": models,
813
- "relationships": rels,
814
- "migrations": migrations,
827
+ "tables": safe_tables,
828
+ "columns": safe_cols,
829
+ "models": safe_models,
830
+ "relationships": safe_rels,
831
+ "migrations": safe_migrations,
815
832
  "env_variables": env_vars,
816
833
  "database_credentials": "REDACTED",
817
- })
834
+ }
835
+ if limit is not None:
836
+ result_dict["limit"] = safe_limit
837
+ result_dict["offset"] = safe_offset
838
+ result_dict["has_more"] = (safe_offset + len(safe_tables)) < len(tables)
839
+
840
+ return redact_payload(result_dict)
818
841
 
819
842
 
820
843
  def get_db_impact(
codegraph/freshness.py CHANGED
@@ -235,30 +235,68 @@ def check_freshness(
235
235
  deleted: list[str] = []
236
236
  parse_failed: list[str] = []
237
237
 
238
- try:
239
- rows = con.execute("SELECT path, hash, status FROM files").fetchall()
240
- except sqlite3.OperationalError:
241
- rows = con.execute("SELECT path, hash, 'ok' AS status FROM files").fetchall()
242
-
243
- for row in rows:
244
- rel = str(row["path"])
245
- if row["status"] == "parse_failed":
246
- parse_failed.append(rel)
247
- if rel not in modified:
248
- modified.append(rel)
249
- abs_path = repository / rel
250
- if not abs_path.exists():
251
- deleted.append(rel)
252
- else:
238
+ # Fast path 1: Git commit diff (branch switch or commit advance)
239
+ git_diff_handled = False
240
+ if cur_commit and idx_commit and cur_commit != idx_commit:
241
+ diff_out = _run_git(repository, "diff", "--name-status", idx_commit, cur_commit)
242
+ if diff_out is not None:
243
+ git_diff_handled = True
244
+ for line in diff_out.splitlines():
245
+ parts = line.strip().split(maxsplit=1)
246
+ if len(parts) == 2:
247
+ action, fpath = parts[0], parts[1]
248
+ if action.startswith("D"):
249
+ deleted.append(fpath)
250
+ else:
251
+ modified.append(fpath)
252
+
253
+ # Fast path 2: Git status when HEAD commit is unchanged
254
+ if not git_diff_handled and cur_commit and idx_commit and cur_commit == idx_commit:
255
+ status_out = _run_git(repository, "status", "--porcelain", "--untracked-files=no")
256
+ if status_out is not None and not status_out.strip():
257
+ # Working tree has zero tracked changes against HEAD
258
+ git_diff_handled = True
253
259
  try:
254
- digest = hashlib.sha256(
255
- abs_path.read_text(encoding="utf-8", errors="replace").encode()
256
- ).hexdigest()
257
- if digest != row["hash"] and rel not in modified:
258
- modified.append(rel)
259
- except OSError:
260
+ pf_rows = con.execute("SELECT path FROM files WHERE status='parse_failed'").fetchall()
261
+ for r in pf_rows:
262
+ parse_failed.append(str(r[0]))
263
+ modified.append(str(r[0]))
264
+ except sqlite3.OperationalError:
265
+ pass
266
+
267
+ if not git_diff_handled:
268
+ try:
269
+ rows = con.execute("SELECT path, hash, status, indexed_at FROM files").fetchall()
270
+ except sqlite3.OperationalError:
271
+ try:
272
+ rows = con.execute("SELECT path, hash, status, 0 AS indexed_at FROM files").fetchall()
273
+ except sqlite3.OperationalError:
274
+ rows = con.execute("SELECT path, hash, 'ok' AS status, 0 AS indexed_at FROM files").fetchall()
275
+
276
+ for row in rows:
277
+ rel = str(row["path"])
278
+ if row["status"] == "parse_failed":
279
+ parse_failed.append(rel)
260
280
  if rel not in modified:
261
281
  modified.append(rel)
282
+ abs_path = repository / rel
283
+ if not abs_path.exists():
284
+ deleted.append(rel)
285
+ else:
286
+ try:
287
+ st = abs_path.stat()
288
+ # Mtime check: skip reading & hashing files strictly older than indexing time
289
+ idx_at = int(row["indexed_at"] or 0)
290
+ if idx_at > 0 and int(st.st_mtime) < idx_at and row["status"] != "parse_failed":
291
+ continue
292
+ digest = hashlib.sha256(
293
+ abs_path.read_text(encoding="utf-8", errors="replace").encode()
294
+ ).hexdigest()
295
+ if digest != row["hash"] and rel not in modified:
296
+ modified.append(rel)
297
+ except OSError:
298
+ if rel not in modified:
299
+ modified.append(rel)
262
300
 
263
301
  changed = len(modified) + len(deleted)
264
302
  if changed == 0 and not parse_failed and (idx_commit == cur_commit or cur_commit is None):
@@ -493,8 +493,11 @@ class Indexer:
493
493
  con = sqlite3.connect(self.db_path)
494
494
  con.row_factory = make_compat_row
495
495
  con.execute("PRAGMA journal_mode = WAL")
496
+ con.execute("PRAGMA synchronous = NORMAL")
496
497
  con.execute("PRAGMA foreign_keys = ON")
497
- con.execute("PRAGMA busy_timeout = 5000")
498
+ con.execute("PRAGMA busy_timeout = 15000")
499
+ con.execute("PRAGMA cache_size = -64000")
500
+ con.execute("PRAGMA temp_store = MEMORY")
498
501
  self._ensure_schema(con)
499
502
  return con
500
503
 
@@ -538,9 +541,13 @@ class Indexer:
538
541
  pass
539
542
  con.commit()
540
543
  else:
541
- # Idempotently ensure all tables from SCHEMA exist
542
- con.executescript(SCHEMA)
543
- con.commit()
544
+ existing_core = con.execute(
545
+ "SELECT count(*) FROM sqlite_master WHERE type='table' "
546
+ "AND name IN ('files', 'symbols', 'graph_edges', 'db_tables', 'runtime_traces')"
547
+ ).fetchone()[0]
548
+ if existing_core < 5:
549
+ con.executescript(SCHEMA)
550
+ con.commit()
544
551
 
545
552
  def _migrate_schema(
546
553
  self, con: sqlite3.Connection, from_version: int, to_version: int
codegraph/installer.py CHANGED
@@ -201,12 +201,15 @@ class InstallerReport:
201
201
  out["error"] = self.error
202
202
  return out
203
203
 
204
- def format_preview_human(self) -> str:
204
+ def format_preview_human(self, *, unicode_override: bool | None = None) -> str:
205
205
  """Render the interactive preview banner before confirmation."""
206
+ from codegraph.cli_output import get_cli_symbols
207
+
208
+ sym = get_cli_symbols(unicode_override=unicode_override)
206
209
  lines: list[str] = ["CodeGraph Installer", ""]
207
210
  lines.append("Detected agents:")
208
211
  for da in self.detected_agents:
209
- mark = "✓" if da.detected else "✗"
212
+ mark = sym.ok if da.detected else sym.error
210
213
  lines.append(f" {mark} {da.display_name}")
211
214
 
212
215
  lines.append("")
@@ -221,11 +224,11 @@ class InstallerReport:
221
224
  lines.append("")
222
225
  lines.append("Installation scope:")
223
226
  if self.location == "global":
224
- lines.append(" (•) Global")
225
- lines.append(" ( ) Current project")
227
+ lines.append(f" {sym.radio_on} Global")
228
+ lines.append(f" {sym.radio_off} Current project")
226
229
  else:
227
- lines.append(" ( ) Global")
228
- lines.append(" (•) Current project")
230
+ lines.append(f" {sym.radio_off} Global")
231
+ lines.append(f" {sym.radio_on} Current project")
229
232
 
230
233
  lines.append("")
231
234
  lines.append("Changes to be made:")
@@ -234,8 +237,11 @@ class InstallerReport:
234
237
  lines.append(" + CodeGraph skill/rules")
235
238
  return "\n".join(lines)
236
239
 
237
- def format_human(self) -> str:
240
+ def format_human(self, *, unicode_override: bool | None = None) -> str:
238
241
  """Render concise, professional human-readable output."""
242
+ from codegraph.cli_output import get_cli_symbols
243
+
244
+ sym = get_cli_symbols(unicode_override=unicode_override)
239
245
  if self.error is not None:
240
246
  err_lines = [
241
247
  f"Error [{self.error.get('code', 'ERROR')}]: {self.error.get('message', '')}",
@@ -256,10 +262,14 @@ class InstallerReport:
256
262
 
257
263
  if self.status == "dry_run":
258
264
  if self.operation == "install":
259
- lines.append(self.format_preview_human())
265
+ lines.append(self.format_preview_human(unicode_override=unicode_override))
260
266
  lines.append("")
261
267
  else:
262
- title = "CodeGraph Uninstaller (Dry Run)" if self.operation == "uninstall" else "CodeGraph Uninit (Dry Run)"
268
+ title = (
269
+ "CodeGraph Uninstaller (Dry Run)"
270
+ if self.operation == "uninstall"
271
+ else "CodeGraph Uninit (Dry Run)"
272
+ )
263
273
  lines.append(title)
264
274
  lines.append("")
265
275
 
@@ -295,15 +305,15 @@ class InstallerReport:
295
305
 
296
306
  if self.operation == "install":
297
307
  for r_msg in self.repairs:
298
- lines.append(f"↻ Repaired: {r_msg}")
308
+ lines.append(f"{sym.repair} Repaired: {r_msg}")
299
309
  for w_msg in self.warnings:
300
- lines.append(f"! Warning: {w_msg}")
310
+ lines.append(f"{sym.warn} Warning: {w_msg}")
301
311
 
302
- lines.append("✓ CodeGraph installed")
312
+ lines.append(f"{sym.ok} CodeGraph installed")
303
313
  if self.verification is not None:
304
- v_mcp = "✓" if self.verification.mcp_config_verified else "✗"
305
- v_inst = "✓" if self.verification.instructions_verified else "✗"
306
- v_srv = "✓" if self.verification.mcp_server_health_verified else "✗"
314
+ v_mcp = sym.ok if self.verification.mcp_config_verified else sym.error
315
+ v_inst = sym.ok if self.verification.instructions_verified else sym.error
316
+ v_srv = sym.ok if self.verification.mcp_server_health_verified else sym.error
307
317
  lines.append(f"{v_mcp} MCP configuration verified")
308
318
  lines.append(f"{v_inst} Agent instructions verified")
309
319
  lines.append(f"{v_srv} MCP server health verified")
@@ -319,23 +329,23 @@ class InstallerReport:
319
329
 
320
330
  if self.operation == "uninstall":
321
331
  for w_msg in self.warnings:
322
- lines.append(f"! Warning: {w_msg}")
332
+ lines.append(f"{sym.warn} Warning: {w_msg}")
323
333
  if removed or modified:
324
334
  for c in [*removed, *modified]:
325
335
  verb = "Removed" if c.action == "remove" else "Updated"
326
- lines.append(f"✓ {verb} {c.display_path}")
336
+ lines.append(f"{sym.ok} {verb} {c.display_path}")
327
337
  else:
328
- lines.append("✓ No CodeGraph agent integrations needed removal")
329
- lines.append("✓ CodeGraph agent integration uninstalled")
338
+ lines.append(f"{sym.ok} No CodeGraph agent integrations needed removal")
339
+ lines.append(f"{sym.ok} CodeGraph agent integration uninstalled")
330
340
  return "\n".join(lines)
331
341
 
332
342
  # uninit
333
343
  if removed:
334
344
  for c in removed:
335
- lines.append(f"✓ Removed {c.display_path}")
345
+ lines.append(f"{sym.ok} Removed {c.display_path}")
336
346
  else:
337
- lines.append("✓ No CodeGraph index files present")
338
- lines.append("✓ Project CodeGraph index removed")
347
+ lines.append(f"{sym.ok} No CodeGraph index files present")
348
+ lines.append(f"{sym.ok} Project CodeGraph index removed")
339
349
  return "\n".join(lines)
340
350
 
341
351
 
@@ -1351,6 +1361,33 @@ def run_install(
1351
1361
  allow_malformed_repair=allow_malformed_repair,
1352
1362
  )
1353
1363
 
1364
+ from codegraph.process_lifecycle import (
1365
+ discover_codegraph_processes,
1366
+ stop_codegraph_processes,
1367
+ )
1368
+
1369
+ proc_discovery = discover_codegraph_processes(clean_stale=not dry_run)
1370
+ current_pid = os.getpid()
1371
+ other_active = [
1372
+ p for p in proc_discovery.active_processes if p.record.pid != current_pid
1373
+ ]
1374
+ orphaned_procs = [p for p in other_active if p.orphaned]
1375
+ live_procs = [p for p in other_active if not p.orphaned]
1376
+
1377
+ if orphaned_procs and dry_run:
1378
+ pids_str = ", ".join(str(p.record.pid) for p in orphaned_procs)
1379
+ warnings.append(
1380
+ f"Orphaned CodeGraph process(es) detected (PID: {pids_str}); "
1381
+ "run 'codegraph stop' or 'codegraph install' to clean up."
1382
+ )
1383
+ if live_procs:
1384
+ pids_str = ", ".join(str(p.record.pid) for p in live_procs)
1385
+ warnings.append(
1386
+ f"Active CodeGraph process(es) running (PID: {pids_str}). "
1387
+ "Before upgrading ('pip install --upgrade codegraph-engine'), run 'codegraph stop' "
1388
+ "to prevent executable file lock errors (WinError 32)."
1389
+ )
1390
+
1354
1391
  preview_report = InstallerReport(
1355
1392
  status="dry_run" if dry_run else "ok",
1356
1393
  operation="install",
@@ -1383,6 +1420,12 @@ def run_install(
1383
1420
  files_changed=False,
1384
1421
  )
1385
1422
 
1423
+ if orphaned_procs:
1424
+ stop_rep = stop_codegraph_processes(only_orphaned=True)
1425
+ stopped_cnt = sum(1 for item in stop_rep.stopped_processes if item.stopped)
1426
+ if stopped_cnt > 0:
1427
+ repairs.append(f"Stopped {stopped_cnt} orphaned CodeGraph background process(es).")
1428
+
1386
1429
  applied = _apply_planned_changes_atomically(changes)
1387
1430
  ver = verify_installation(
1388
1431
  base_root=base_root,
@@ -1545,6 +1588,13 @@ def run_uninstall(
1545
1588
  files_changed=False,
1546
1589
  )
1547
1590
 
1591
+ from codegraph.process_lifecycle import stop_codegraph_processes
1592
+
1593
+ stop_codegraph_processes(
1594
+ repository=None if scope == "global" else effective_ws,
1595
+ stop_all=(scope == "global"),
1596
+ )
1597
+
1548
1598
  applied = _apply_planned_changes_atomically(planned)
1549
1599
  return InstallerReport(
1550
1600
  status="ok",
@@ -1979,6 +1979,8 @@ def list_routes(
1979
1979
  framework: str | None = None,
1980
1980
  method: str | None = None,
1981
1981
  path: str | None = None,
1982
+ limit: int | None = None,
1983
+ offset: int = 0,
1982
1984
  ) -> dict[str, Any]:
1983
1985
  """Expose application routes discovered from the repository."""
1984
1986
  unindexed = check_index_available(con, repository)
@@ -2028,10 +2030,27 @@ def list_routes(
2028
2030
  for r in rows
2029
2031
  ]
2030
2032
 
2033
+ total_count = len(routes)
2034
+ if limit is not None:
2035
+ safe_offset = max(0, offset)
2036
+ safe_limit = max(0, limit)
2037
+ paginated_routes = routes[safe_offset : safe_offset + safe_limit]
2038
+ return {
2039
+ "status": "ok",
2040
+ **meta,
2041
+ "count": len(paginated_routes),
2042
+ "total_count": total_count,
2043
+ "limit": safe_limit,
2044
+ "offset": safe_offset,
2045
+ "has_more": (safe_offset + len(paginated_routes)) < total_count,
2046
+ "routes": paginated_routes,
2047
+ }
2048
+
2031
2049
  return {
2032
2050
  "status": "ok",
2033
2051
  **meta,
2034
2052
  "count": len(routes),
2053
+ "total_count": total_count,
2035
2054
  "routes": routes,
2036
2055
  }
2037
2056