descry-codegraph 0.1.0__tar.gz → 0.1.1__tar.gz

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 (50) hide show
  1. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/CHANGELOG.md +38 -0
  2. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/PKG-INFO +1 -1
  3. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/pyproject.toml +1 -1
  4. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/ast_grep.py +0 -26
  5. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/cross_lang.py +2 -54
  6. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/embeddings.py +0 -41
  7. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/generate.py +0 -1
  8. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/handlers.py +42 -13
  9. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/mcp_server.py +7 -2
  10. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/query.py +0 -1
  11. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/scip/parser.py +0 -22
  12. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/web/server.py +34 -11
  13. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_config.py +44 -0
  14. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_handlers.py +0 -1
  15. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/.github/workflows/ci.yml +0 -0
  16. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/.github/workflows/release.yml +0 -0
  17. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/.gitignore +0 -0
  18. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/CLAUDE.md +0 -0
  19. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/LICENSE +0 -0
  20. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/README.md +0 -0
  21. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/SECURITY.md +0 -0
  22. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/justfile +0 -0
  23. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/pi-extension/README.md +0 -0
  24. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/pi-extension/descry-cli.py +0 -0
  25. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/pi-extension/descry-tools.ts +0 -0
  26. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/__init__.py +0 -0
  27. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/_env.py +0 -0
  28. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/_graph.py +0 -0
  29. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/cli.py +0 -0
  30. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/git_history.py +0 -0
  31. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/scip/__init__.py +0 -0
  32. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/scip/cache.py +0 -0
  33. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/scip/pb2.py +0 -0
  34. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/scip/support.py +0 -0
  35. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/tree_sitter_parser.py +0 -0
  36. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/web/__init__.py +0 -0
  37. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/web/web/index.html +0 -0
  38. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/src/descry/web/web/style.css +0 -0
  39. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/__init__.py +0 -0
  40. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/conftest.py +0 -0
  41. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/fixtures/small_graph.json +0 -0
  42. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_cli.py +0 -0
  43. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_generate.py +0 -0
  44. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_git_history.py +0 -0
  45. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_hardening.py +0 -0
  46. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_mcp.py +0 -0
  47. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_query.py +0 -0
  48. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_scip.py +0 -0
  49. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_tree_sitter_parser.py +0 -0
  50. {descry_codegraph-0.1.0 → descry_codegraph-0.1.1}/tests/test_web_server.py +0 -0
@@ -4,6 +4,44 @@ All notable changes to Descry will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to pre-1.0 semver (minor-version bumps may include breaking changes; see README versioning stance).
6
6
 
7
+ ## [0.1.1] — 2026-04-17
8
+
9
+ Patch release focused on making cross-language tracing actually configurable,
10
+ plus a round of dead-code cleanup. No breaking changes.
11
+
12
+ ### Fixed
13
+
14
+ - **Cross-language tracing is now configurable from `.descry.toml`.** The
15
+ `DescryConfig.openapi_path` field existed in 0.1.0 but had no TOML loader
16
+ and the web `/api/cross-lang` handler hardcoded `public/api/latest.json`,
17
+ so custom spec locations silently did nothing. Added a `[cross_lang]`
18
+ section with `openapi_path`, `backend_handler_patterns`,
19
+ `frontend_api_patterns`, and `api_prefixes` keys. Web and CLI/MCP now
20
+ both honour the config and pass all four through to `CrossLangTracer`.
21
+ - `[cross_lang] openapi_path` is containment-checked against the project
22
+ root; a crafted `.descry.toml` cannot point the indexer at files outside
23
+ the configured project.
24
+
25
+ ### Removed
26
+
27
+ Dead code with no call sites anywhere in the tree was pruned:
28
+
29
+ - `DescryConfig.project_markers` (auto-detect used a module constant).
30
+ - `DescryConfig.use_tree_sitter_ts` (scaffolding field with no consumer).
31
+ - `descry.query.MAX_INLINE_THRESHOLD` constant.
32
+ - `CrossLangTracer.endpoint_to_node_id()` and module-level
33
+ `_create_cross_lang_edges()` helper.
34
+ - `SemanticSearcher._find_similar()` (distinct from
35
+ `GraphQuerier._find_similar_nodes`, which remains).
36
+ - `descry.ast_grep.extract_imports_typescript_batch()`.
37
+ - `ScipIndex.get_definition_location()` and `get_symbol_info()`.
38
+ - `TypeScriptSymbolTable.file_dir` attribute.
39
+ - `DescryService._clear_dedup_cache()` (superseded by `reset_caches()` in
40
+ the 0.1.0 hardening sweep).
41
+
42
+ A `vulture --min-confidence 80` sweep on `src/descry/` now reports zero
43
+ findings outside generated protobuf code.
44
+
7
45
  ## [0.1.0] — 2026-04-16
8
46
 
9
47
  Initial public PyPI release. Descry is a polyglot codebase knowledge graph toolkit with three interfaces: CLI (`descry`), MCP server (`descry-mcp`), and local web UI (`descry-web`).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: descry-codegraph
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Codebase knowledge graph: polyglot indexer, semantic search, call-graph analysis
5
5
  Project-URL: Homepage, https://github.com/06chaynes/descry
6
6
  Project-URL: Repository, https://github.com/06chaynes/descry
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "descry-codegraph"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "Codebase knowledge graph: polyglot indexer, semantic search, call-graph analysis"
5
5
  requires-python = ">=3.11"
6
6
  authors = [{name = "Christian Haynes"}]
@@ -354,29 +354,3 @@ def extract_imports_typescript(file_path: str) -> dict:
354
354
  continue
355
355
 
356
356
  return result
357
-
358
-
359
- def extract_imports_typescript_batch(file_paths: list) -> dict:
360
- """Extract imports from multiple TypeScript files efficiently.
361
-
362
- Processes files individually but caches results for repeated calls.
363
-
364
- Args:
365
- file_paths: List of file paths to process
366
-
367
- Returns:
368
- Dict mapping file paths to their import data:
369
- {
370
- "path/to/file.ts": {
371
- "imports": {"name": ("module", "type")},
372
- "namespaces": {"alias": "module"},
373
- }
374
- }
375
- """
376
- results = {}
377
- for file_path in file_paths:
378
- try:
379
- results[file_path] = extract_imports_typescript(file_path)
380
- except Exception:
381
- results[file_path] = {"imports": {}, "namespaces": {}}
382
- return results
@@ -185,7 +185,7 @@ class CrossLangTracer:
185
185
  return self.path_to_operation[key]["operationId"]
186
186
 
187
187
  # Try pattern matching for paths with IDs
188
- for pattern_method, template_path, pattern, info in self.endpoint_patterns:
188
+ for pattern_method, _, pattern, info in self.endpoint_patterns:
189
189
  if pattern_method == method_upper and pattern.match(path):
190
190
  return info["operationId"]
191
191
 
@@ -197,21 +197,6 @@ class CrossLangTracer:
197
197
 
198
198
  return None
199
199
 
200
- def endpoint_to_node_id(self, method: str, path: str) -> Optional[str]:
201
- """Find the graph node ID for a backend handler.
202
-
203
- Args:
204
- method: HTTP method
205
- path: API path
206
-
207
- Returns:
208
- Full node ID (e.g., "FILE:backend/src/routes/actions/handlers.rs::list_actions")
209
- """
210
- op_id = self.endpoint_to_handler(method, path)
211
- if op_id:
212
- return self.operation_to_handler.get(op_id)
213
- return None
214
-
215
200
  def get_handler_info(self, method: str, path: str) -> Optional[dict]:
216
201
  """Get full information about an endpoint's handler.
217
202
 
@@ -227,7 +212,7 @@ class CrossLangTracer:
227
212
  return info
228
213
 
229
214
  # Try pattern matching
230
- for pattern_method, template_path, pattern, info in self.endpoint_patterns:
215
+ for pattern_method, _, pattern, info in self.endpoint_patterns:
231
216
  if pattern_method == method_upper and pattern.match(path):
232
217
  result = info.copy()
233
218
  result["node_id"] = self.operation_to_handler.get(info["operationId"])
@@ -349,43 +334,6 @@ class CrossLangTracer:
349
334
  }
350
335
 
351
336
 
352
- def _create_cross_lang_edges(
353
- graph_data: dict,
354
- openapi_path: str,
355
- ) -> list[dict]:
356
- """Create CALLS_API edges linking frontend to backend handlers.
357
-
358
- This can be used during graph generation to add cross-language edges.
359
-
360
- Args:
361
- graph_data: Current graph data (nodes and edges)
362
- openapi_path: Path to OpenAPI spec
363
-
364
- Returns:
365
- List of new edges to add
366
- """
367
- tracer = CrossLangTracer(openapi_path)
368
- api_calls = tracer.find_ts_api_calls(graph_data)
369
-
370
- edges = []
371
- for call in api_calls:
372
- if call.get("rust_node_id"):
373
- edges.append(
374
- {
375
- "source": call["ts_node_id"],
376
- "target": call["rust_node_id"],
377
- "relation": "CALLS_API",
378
- "metadata": {
379
- "endpoint": call["inferred_endpoint"],
380
- "method": call["inferred_method"],
381
- "handler": call["rust_handler"],
382
- },
383
- }
384
- )
385
-
386
- return edges
387
-
388
-
389
337
  if __name__ == "__main__":
390
338
  # Generic stats-only smoke test. Prints OpenAPI + graph linkage stats.
391
339
  import sys
@@ -441,47 +441,6 @@ class SemanticSearcher:
441
441
  results.sort(key=lambda x: x[1], reverse=True)
442
442
  return results[:limit]
443
443
 
444
- def _find_similar(self, node_id: str, limit: int = 5) -> list:
445
- """Find nodes semantically similar to a given node.
446
-
447
- Args:
448
- node_id: The node ID to find similar nodes for
449
- limit: Maximum number of results
450
-
451
- Returns:
452
- List of (node, score) tuples sorted by similarity
453
- """
454
- # Find the node index
455
- node_idx = None
456
- for i, node in enumerate(self.nodes):
457
- if node["id"] == node_id:
458
- node_idx = i
459
- break
460
-
461
- if node_idx is None:
462
- return []
463
-
464
- # Use that node's embedding as query
465
- node_embedding = self.embeddings[node_idx]
466
-
467
- # Compute similarities
468
- similarities = np.dot(self.embeddings, node_embedding) / (
469
- np.linalg.norm(self.embeddings, axis=1) * np.linalg.norm(node_embedding)
470
- )
471
-
472
- # Get top results (excluding the query node itself)
473
- top_indices = np.argsort(similarities)[::-1]
474
- results = []
475
- for idx in top_indices:
476
- if idx == node_idx:
477
- continue
478
- score = float(similarities[idx])
479
- results.append((self.nodes[idx], score))
480
- if len(results) >= limit:
481
- break
482
-
483
- return results
484
-
485
444
 
486
445
  # Convenience function for one-off searches (with thread safety)
487
446
  _cached_searcher = None
@@ -1399,7 +1399,6 @@ class TypeScriptSymbolTable:
1399
1399
  project_root: Root directory of the project
1400
1400
  """
1401
1401
  self.file_path = file_path
1402
- self.file_dir = os.path.dirname(file_path)
1403
1402
  self.project_root = project_root
1404
1403
 
1405
1404
  # local_name -> (module_path, import_type)
@@ -174,14 +174,7 @@ class DescryConfig:
174
174
  max_stale_hours: float = 24
175
175
  enable_scip: bool = True
176
176
  enable_embeddings: bool = True
177
- # Opt-in: use tree-sitter for TS/TSX/JS first-pass parsing. Requires
178
- # descry-codegraph[ast]. Falls back to regex per-file when the grammar
179
- # hits errors or the extra isn't installed.
180
- use_tree_sitter_ts: bool = False
181
177
  openapi_path: Path | None = None
182
- project_markers: list[str] = field(
183
- default_factory=lambda: list(_DEFAULT_PROJECT_MARKERS)
184
- )
185
178
  backend_handler_patterns: list[str] = field(default_factory=list)
186
179
  frontend_api_patterns: list[str] = field(default_factory=list)
187
180
  api_prefixes: list[str] = field(default_factory=lambda: list(_DEFAULT_API_PREFIXES))
@@ -316,8 +309,6 @@ class DescryConfig:
316
309
  self.enable_scip = features["enable_scip"]
317
310
  if "enable_embeddings" in features:
318
311
  self.enable_embeddings = features["enable_embeddings"]
319
- if "use_tree_sitter_ts" in features:
320
- self.use_tree_sitter_ts = features["use_tree_sitter_ts"]
321
312
 
322
313
  # [embeddings]
323
314
  embeddings = data.get("embeddings", {})
@@ -388,6 +379,41 @@ class DescryConfig:
388
379
  if "skip_crates" in scip:
389
380
  self.scip_skip_crates = scip["skip_crates"]
390
381
 
382
+ # [cross_lang]
383
+ cross_lang = data.get("cross_lang", {})
384
+ if "backend_handler_patterns" in cross_lang:
385
+ patterns = cross_lang["backend_handler_patterns"]
386
+ if isinstance(patterns, list):
387
+ self.backend_handler_patterns = [str(p) for p in patterns]
388
+ if "frontend_api_patterns" in cross_lang:
389
+ patterns = cross_lang["frontend_api_patterns"]
390
+ if isinstance(patterns, list):
391
+ self.frontend_api_patterns = [str(p) for p in patterns]
392
+ if "api_prefixes" in cross_lang:
393
+ prefixes = cross_lang["api_prefixes"]
394
+ if isinstance(prefixes, list):
395
+ self.api_prefixes = [str(p) for p in prefixes]
396
+ if "openapi_path" in cross_lang:
397
+ raw = cross_lang["openapi_path"]
398
+ if isinstance(raw, str) and raw:
399
+ candidate = Path(raw)
400
+ if not candidate.is_absolute():
401
+ candidate = self.project_root / candidate
402
+ # Containment check: keep the spec inside project_root so a
403
+ # crafted .descry.toml can't point descry at arbitrary files.
404
+ try:
405
+ resolved = candidate.resolve(strict=False)
406
+ root = self.project_root.resolve()
407
+ if resolved == root or root in resolved.parents:
408
+ self.openapi_path = resolved
409
+ else:
410
+ logger.warning(
411
+ f"[cross_lang] openapi_path {raw!r} resolves outside "
412
+ f"project root; ignoring"
413
+ )
414
+ except (OSError, RuntimeError) as e:
415
+ logger.warning(f"Invalid [cross_lang] openapi_path: {e}")
416
+
391
417
  # [scip.rust]
392
418
  scip_rust = scip.get("rust", {})
393
419
  if "toolchain" in scip_rust:
@@ -752,9 +778,6 @@ class DescryService:
752
778
  del self._dedup_cache[key]
753
779
  self._dedup_cache[content_hash] = (graph_mtime, node_id)
754
780
 
755
- def _clear_dedup_cache(self):
756
- self._dedup_cache = {}
757
-
758
781
  def reset_caches(self):
759
782
  """Reset all cached instances. Call after reindex."""
760
783
  self._graph_cache = {"mtime": 0, "nodes": 0, "edges": 0}
@@ -1712,7 +1735,13 @@ class DescryService:
1712
1735
  graph_path = (
1713
1736
  str(self.config.graph_path) if self.config.graph_path.exists() else None
1714
1737
  )
1715
- tracer = self._CrossLangTracer(str(openapi_path), graph_path)
1738
+ tracer = self._CrossLangTracer(
1739
+ str(openapi_path),
1740
+ graph_path,
1741
+ backend_handler_patterns=self.config.backend_handler_patterns or None,
1742
+ frontend_api_patterns=self.config.frontend_api_patterns or None,
1743
+ api_prefixes=self.config.api_prefixes or None,
1744
+ )
1716
1745
 
1717
1746
  if mode == "stats":
1718
1747
  stats = tracer.get_stats()
@@ -94,8 +94,13 @@ _service: DescryService | None = None
94
94
 
95
95
 
96
96
  @asynccontextmanager
97
- async def server_lifespan(server: FastMCP):
98
- """Pre-warm graph and embeddings on startup."""
97
+ async def server_lifespan(_server: FastMCP):
98
+ """Pre-warm graph and embeddings on startup.
99
+
100
+ ``_server`` is the required lifespan-hook parameter injected by
101
+ FastMCP; the hook only mutates module-level state, so the arg is
102
+ intentionally unused.
103
+ """
99
104
  global _service
100
105
  config = DescryConfig.from_env()
101
106
  _service = DescryService(config)
@@ -19,7 +19,6 @@ MAX_CHILDREN_PER_LEVEL = 10 # Branching limit per level
19
19
  # Token budgets and thresholds
20
20
  DEFAULT_TOKEN_BUDGET = 2000 # Default token budget for expanded callees
21
21
  CALLEE_INLINE_THRESHOLD = 150 # Inline callees smaller than this (was 100)
22
- MAX_INLINE_THRESHOLD = 300 # Maximum allowed inline threshold
23
22
  MAX_CALLERS_SHOWN = 15 # Maximum callers to show in context
24
23
 
25
24
  # Timeout (milliseconds)
@@ -481,28 +481,6 @@ class ScipIndex:
481
481
 
482
482
  return names
483
483
 
484
- def get_definition_location(self, symbol_id: str) -> Optional[Tuple[str, int]]:
485
- """Get the definition location for a symbol.
486
-
487
- Args:
488
- symbol_id: SCIP symbol identifier
489
-
490
- Returns:
491
- Tuple of (file_path, line_number) or None if not found
492
- """
493
- return self.definitions.get(symbol_id)
494
-
495
- def get_symbol_info(self, symbol_id: str) -> Optional[dict]:
496
- """Get metadata for a symbol.
497
-
498
- Args:
499
- symbol_id: SCIP symbol identifier
500
-
501
- Returns:
502
- Dictionary with kind, display_name, documentation, or None
503
- """
504
- return self.symbols.get(symbol_id)
505
-
506
484
  def get_stats(self) -> dict:
507
485
  """Get statistics about the loaded index.
508
486
 
@@ -276,13 +276,28 @@ def _caller_to_dict(caller_id: str, q: GraphQuerier) -> dict:
276
276
  return result
277
277
 
278
278
 
279
- def _openapi_path_exists() -> bool:
280
- """Check if an OpenAPI spec file exists in the project."""
279
+ def _resolve_openapi_path() -> Path | None:
280
+ """Locate the project's OpenAPI spec, if any.
281
+
282
+ Order of precedence:
283
+ 1. `[cross_lang] openapi_path` in .descry.toml (absolute or project-
284
+ relative; resolved by DescryConfig with a containment check).
285
+ 2. `public/api/latest.json` under the project root.
286
+ 3. `public/api/openapi.json` under the project root.
287
+ """
281
288
  cfg = _get_config()
289
+ if cfg.openapi_path and cfg.openapi_path.exists():
290
+ return cfg.openapi_path
282
291
  for name in ("latest.json", "openapi.json"):
283
- if (cfg.project_root / "public" / "api" / name).exists():
284
- return True
285
- return False
292
+ candidate = cfg.project_root / "public" / "api" / name
293
+ if candidate.exists():
294
+ return candidate
295
+ return None
296
+
297
+
298
+ def _openapi_path_exists() -> bool:
299
+ """Check if an OpenAPI spec file exists in the project."""
300
+ return _resolve_openapi_path() is not None
286
301
 
287
302
 
288
303
  # --- API Endpoint Handlers ---
@@ -857,19 +872,27 @@ async def api_cross_lang(request: Request) -> JSONResponse:
857
872
  tag = request.query_params.get("tag")
858
873
 
859
874
  cfg = _get_config()
860
- openapi_path = cfg.project_root / "public" / "api" / "latest.json"
861
- if not openapi_path.exists():
862
- openapi_path = cfg.project_root / "public" / "api" / "openapi.json"
863
- if not openapi_path.exists():
875
+ openapi_path = _resolve_openapi_path()
876
+ if openapi_path is None:
864
877
  return JSONResponse(
865
878
  {
866
879
  "not_configured": True,
867
- "message": "No OpenAPI spec found. Place your spec at public/api/openapi.json relative to the project root.",
880
+ "message": (
881
+ "No OpenAPI spec found. Set [cross_lang] openapi_path in "
882
+ ".descry.toml, or place a spec at public/api/openapi.json "
883
+ "or public/api/latest.json relative to the project root."
884
+ ),
868
885
  }
869
886
  )
870
887
 
871
888
  graph_path = str(cfg.graph_path) if cfg.graph_path.exists() else None
872
- tracer = CrossLangTracer(str(openapi_path), graph_path)
889
+ tracer = CrossLangTracer(
890
+ str(openapi_path),
891
+ graph_path,
892
+ backend_handler_patterns=cfg.backend_handler_patterns or None,
893
+ frontend_api_patterns=cfg.frontend_api_patterns or None,
894
+ api_prefixes=cfg.api_prefixes or None,
895
+ )
873
896
 
874
897
  if mode == "stats":
875
898
  return JSONResponse(tracer.get_stats())
@@ -268,6 +268,50 @@ extra_args = []
268
268
  assert config.syntax_lang_map[".rs"] == "rust"
269
269
  assert config.syntax_lang_map[".py"] == "python"
270
270
 
271
+ def test_cross_lang_section(self, tmp_path):
272
+ """[cross_lang] openapi_path + patterns reach DescryConfig."""
273
+ (tmp_path / "spec.json").write_text('{"openapi": "3.0.0"}')
274
+ (tmp_path / ".descry.toml").write_text(
275
+ 'openapi_path = "spec.json"\n'
276
+ 'backend_handler_patterns = ["handlers/"]\n'
277
+ 'frontend_api_patterns = ["src/api/"]\n'
278
+ 'api_prefixes = ["/api/v1", "/api/v2"]\n'.replace(
279
+ "openapi_path", "[cross_lang]\nopenapi_path"
280
+ )
281
+ )
282
+ config = DescryConfig(project_root=tmp_path)
283
+ config._apply_toml(DescryConfig._load_toml(tmp_path))
284
+
285
+ assert config.openapi_path == (tmp_path / "spec.json").resolve()
286
+ assert config.backend_handler_patterns == ["handlers/"]
287
+ assert config.frontend_api_patterns == ["src/api/"]
288
+ assert config.api_prefixes == ["/api/v1", "/api/v2"]
289
+
290
+ def test_cross_lang_openapi_path_outside_root_rejected(self, tmp_path, caplog):
291
+ """Paths resolving outside project_root are ignored with a warning."""
292
+ import logging
293
+
294
+ (tmp_path / ".descry.toml").write_text(
295
+ '[cross_lang]\nopenapi_path = "/etc/passwd"\n'
296
+ )
297
+ config = DescryConfig(project_root=tmp_path)
298
+ with caplog.at_level(logging.WARNING):
299
+ config._apply_toml(DescryConfig._load_toml(tmp_path))
300
+ assert config.openapi_path is None
301
+ assert any("outside project root" in r.message for r in caplog.records)
302
+
303
+ def test_cross_lang_openapi_path_absolute_inside_root(self, tmp_path):
304
+ """Absolute paths that resolve inside project_root are accepted."""
305
+ (tmp_path / "public").mkdir()
306
+ spec = tmp_path / "public" / "openapi.json"
307
+ spec.write_text('{"openapi":"3.0.0"}')
308
+ (tmp_path / ".descry.toml").write_text(
309
+ f'[cross_lang]\nopenapi_path = "{spec}"\n'
310
+ )
311
+ config = DescryConfig(project_root=tmp_path)
312
+ config._apply_toml(DescryConfig._load_toml(tmp_path))
313
+ assert config.openapi_path == spec.resolve()
314
+
271
315
 
272
316
  # --- Precedence ---
273
317
 
@@ -23,7 +23,6 @@ class TestDescryConfig:
23
23
  assert config.max_stale_hours == 24
24
24
  assert config.enable_scip is True
25
25
  assert config.enable_embeddings is True
26
- assert ".git" in config.project_markers
27
26
 
28
27
  def test_from_env(self):
29
28
  config = DescryConfig.from_env()