sciogen 0.1.2__tar.gz → 0.1.4__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 (58) hide show
  1. {sciogen-0.1.2 → sciogen-0.1.4}/.gitignore +4 -0
  2. {sciogen-0.1.2 → sciogen-0.1.4}/PKG-INFO +7 -1
  3. {sciogen-0.1.2 → sciogen-0.1.4}/README.md +6 -0
  4. {sciogen-0.1.2 → sciogen-0.1.4}/pyproject.toml +1 -1
  5. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/__init__.py +1 -1
  6. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/explore/template.html +132 -42
  7. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/mcp/server.py +45 -0
  8. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/query/engine.py +30 -3
  9. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/stores/meta.py +41 -0
  10. {sciogen-0.1.2 → sciogen-0.1.4}/site/docs.html +96 -2
  11. {sciogen-0.1.2 → sciogen-0.1.4}/site/index.html +49 -0
  12. sciogen-0.1.4/tests/test_mcp_server.py +42 -0
  13. {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_pipeline_and_query.py +33 -0
  14. {sciogen-0.1.2 → sciogen-0.1.4}/LICENSE +0 -0
  15. {sciogen-0.1.2 → sciogen-0.1.4}/docs/architecture.md +0 -0
  16. {sciogen-0.1.2 → sciogen-0.1.4}/docs/cli.md +0 -0
  17. {sciogen-0.1.2 → sciogen-0.1.4}/docs/mcp.md +0 -0
  18. {sciogen-0.1.2 → sciogen-0.1.4}/docs/quickstart.md +0 -0
  19. {sciogen-0.1.2 → sciogen-0.1.4}/docs/schema.md +0 -0
  20. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/__main__.py +0 -0
  21. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/api.py +0 -0
  22. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/__init__.py +0 -0
  23. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/banner.py +0 -0
  24. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/console.py +0 -0
  25. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/explore/__init__.py +0 -0
  26. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/main.py +0 -0
  27. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/config.py +0 -0
  28. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/discovery.py +0 -0
  29. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/embed/__init__.py +0 -0
  30. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/embed/hashing.py +0 -0
  31. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/embed/nomic.py +0 -0
  32. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/index/__init__.py +0 -0
  33. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/index/pipeline.py +0 -0
  34. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/ir.py +0 -0
  35. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/mcp/__init__.py +0 -0
  36. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/__init__.py +0 -0
  37. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/base.py +0 -0
  38. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/generic.py +0 -0
  39. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/javascript.py +0 -0
  40. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/python.py +0 -0
  41. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/parsing/__init__.py +0 -0
  42. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/parsing/languages.py +0 -0
  43. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/parsing/parser.py +0 -0
  44. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/query/__init__.py +0 -0
  45. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/query/results.py +0 -0
  46. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/resolve/__init__.py +0 -0
  47. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/resolve/resolver.py +0 -0
  48. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/stores/__init__.py +0 -0
  49. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/stores/graph.py +0 -0
  50. {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/stores/vectors.py +0 -0
  51. {sciogen-0.1.2 → sciogen-0.1.4}/site/public/icon.png +0 -0
  52. {sciogen-0.1.2 → sciogen-0.1.4}/site/public/logo.png +0 -0
  53. {sciogen-0.1.2 → sciogen-0.1.4}/tests/conftest.py +0 -0
  54. {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_cli.py +0 -0
  55. {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_discovery.py +0 -0
  56. {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_graph_store.py +0 -0
  57. {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_normalize_python.py +0 -0
  58. {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_resolver.py +0 -0
@@ -22,6 +22,7 @@ env/
22
22
  htmlcov/
23
23
  tools/
24
24
  .claude/
25
+ blog/
25
26
 
26
27
  # Local tool settings
27
28
  .claude/settings.local.json
@@ -38,3 +39,6 @@ CLAUDE.md
38
39
  RELEASING.md
39
40
  site/_artifact_*.html
40
41
  docs/api.md
42
+
43
+ # Local change log (personal, uncommitted)
44
+ CHANGELOG.local.md
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sciogen
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Codebase intelligence layer — a queryable knowledge graph over your code.
5
5
  Project-URL: Homepage, https://ayanbag.github.io/sciogen/
6
6
  Project-URL: Repository, https://github.com/ayanbag/sciogen
@@ -36,6 +36,12 @@ Description-Content-Type: text/markdown
36
36
 
37
37
  <img src="site/public/logo.png" alt="Image" width="100%" />
38
38
 
39
+
40
+
41
+
42
+
43
+
44
+
39
45
  <div align="center">
40
46
  <p>
41
47
  <a href="https://pypi.org/project/sciogen/"><img alt="PyPI" src="https://img.shields.io/pypi/v/sciogen.svg"></a>
@@ -1,5 +1,11 @@
1
1
  <img src="site/public/logo.png" alt="Image" width="100%" />
2
2
 
3
+
4
+
5
+
6
+
7
+
8
+
3
9
  <div align="center">
4
10
  <p>
5
11
  <a href="https://pypi.org/project/sciogen/"><img alt="PyPI" src="https://img.shields.io/pypi/v/sciogen.svg"></a>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sciogen"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "Codebase intelligence layer — a queryable knowledge graph over your code."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -19,6 +19,6 @@ Public entry point::
19
19
 
20
20
  from sciogen.api import SciogenGraph, open # noqa: A004 - deliberate, mirrors sqlite3.connect ergonomics
21
21
 
22
- __version__ = "0.1.1"
22
+ __version__ = "0.1.4"
23
23
 
24
24
  __all__ = ["SciogenGraph", "open", "__version__"]
@@ -284,7 +284,8 @@ GRAPH.edges.forEach(e => {
284
284
  if (adjacency.has(e.dst)) adjacency.get(e.dst).push(e);
285
285
  });
286
286
  const degree = id => GRAPH.in_degrees[id] || 0;
287
- const maxDegree = Math.max(1, ...Object.values(GRAPH.in_degrees));
287
+ let maxDegree = 1;
288
+ for (const v of Object.values(GRAPH.in_degrees)) if (v > maxDegree) maxDegree = v;
288
289
  const nodeSize = id => 22 + 36 * Math.sqrt(degree(id) / maxDegree);
289
290
  const shortLabel = n => n.kind === "FileNode" ? n.name.split("/").pop() : (n.name.split(".").pop() || n.name);
290
291
 
@@ -293,6 +294,8 @@ const visible = new Set(GRAPH.nodes.filter(n => n.kind === "FileNode").map(n =>
293
294
  const revealedBy = new Map();
294
295
  const expanded = new Set();
295
296
  const hiddenKinds = new Set();
297
+ const savedPos = new Map(); // id → {x,y}; lets nodes reappear where they were
298
+ const LAYOUT_ANIM_MAX = 700; // above this many visible nodes, layout runs without animation
296
299
  let minConf = 0;
297
300
  let currentLayout = "force";
298
301
  let shownEdges = 0;
@@ -374,45 +377,129 @@ const cy = cytoscape({
374
377
  wheelSensitivity: 0.25,
375
378
  });
376
379
 
377
- /* stagger-fade the initial file nodes in */
380
+ /* persist node positions after any layout or manual drag, so incremental
381
+ updates never re-simulate — reappearing nodes land where they were */
382
+ cy.on("layoutstop", () => {
383
+ cy.nodes().forEach(n => savedPos.set(n.id(), { x: n.position("x"), y: n.position("y") }));
384
+ });
385
+ cy.on("dragfree", "node", e =>
386
+ savedPos.set(e.target.id(), { x: e.target.position("x"), y: e.target.position("y") }));
387
+
388
+ /* fade the initial file nodes in as one group (not one timer per node) */
378
389
  if (!reduce) {
379
- cy.nodes().forEach((n, i) => {
380
- n.style("opacity", 0);
381
- setTimeout(() => n.animate({ style: { opacity: 1 } }, { duration: 260, easing: "ease-out" }), 24 * i);
390
+ const first = cy.nodes();
391
+ first.style("opacity", 0);
392
+ first.animate({ style: { opacity: 1 } }, { duration: 300, easing: "ease-out" });
393
+ }
394
+
395
+ /* Desired on-screen sets. Edges are collected by walking only the adjacency
396
+ of visible nodes — never the full GRAPH.edges array — so cost scales with
397
+ what's shown, not with the size of the whole codebase. */
398
+ function computeWanted() {
399
+ const wantNodes = new Set();
400
+ for (const id of visible) {
401
+ const n = nodesById.get(id);
402
+ if (n && !hiddenKinds.has(n.kind)) wantNodes.add(id);
403
+ }
404
+ const wantEdges = new Map();
405
+ for (const id of wantNodes) {
406
+ const adj = adjacency.get(id);
407
+ if (!adj) continue;
408
+ for (const e of adj) {
409
+ if (e.kind === "DEFINES" || e.kind === "PART_OF") continue;
410
+ if (e.confidence < minConf) continue;
411
+ if (!wantNodes.has(e.src) || !wantNodes.has(e.dst)) continue;
412
+ const key = e.src + "→" + e.dst + "→" + e.kind;
413
+ if (!wantEdges.has(key)) wantEdges.set(key, e);
414
+ }
415
+ }
416
+ shownEdges = wantEdges.size;
417
+ return { wantNodes, wantEdges };
418
+ }
419
+
420
+ /* Place freshly-revealed nodes in a ring around whatever expanded them —
421
+ no physics, no global relayout, existing nodes never move. */
422
+ function positionNew(added, curNodes) {
423
+ const groups = new Map();
424
+ added.forEach(n => {
425
+ const id = n.id();
426
+ if (savedPos.has(id)) { n.position(savedPos.get(id)); return; }
427
+ const by = revealedBy.get(id) || "__root";
428
+ (groups.get(by) || groups.set(by, []).get(by)).push(n);
382
429
  });
430
+ // position groups whose anchor already existed first, so anchors added in
431
+ // this same batch are themselves placed before their children reference them
432
+ const order = [...groups.keys()].sort((a, b) =>
433
+ (curNodes.has(a) ? 0 : 1) - (curNodes.has(b) ? 0 : 1));
434
+ for (const by of order) {
435
+ const members = groups.get(by);
436
+ const anchor = by !== "__root" ? cy.getElementById(by) : null;
437
+ const has = anchor && anchor.nonempty();
438
+ const cx = has ? anchor.position("x") : cy.width() / 2;
439
+ const cyy = has ? anchor.position("y") : cy.height() / 2;
440
+ const r = 55 + members.length * 7;
441
+ members.forEach((m, i) => {
442
+ const a = (2 * Math.PI * i) / members.length - Math.PI / 2;
443
+ m.position({ x: cx + r * Math.cos(a), y: cyy + r * Math.sin(a) });
444
+ savedPos.set(m.id(), { x: m.position("x"), y: m.position("y") });
445
+ });
446
+ }
383
447
  }
384
448
 
449
+ /* Incremental reconciliation: diff current vs wanted, add/remove only the
450
+ delta inside one batched frame. No cy.json() teardown, no relayout. */
385
451
  function refresh(opts) {
386
452
  opts = opts || {};
387
- const before = new Set(cy.nodes().map(n => n.id()));
388
- const els = buildElements();
389
- const after = new Set(els.filter(e => !e.data.source).map(e => e.data.id));
390
-
391
- const doUpdate = () => {
392
- cy.json({ elements: els });
393
- cy.style(cyStyle());
394
- if (!reduce) {
395
- cy.nodes().forEach(n => {
396
- if (!before.has(n.id())) {
397
- n.style("opacity", 0);
398
- n.animate({ style: { opacity: 1 } }, { duration: 240, easing: "ease-out" });
399
- }
400
- });
453
+ const { wantNodes, wantEdges } = computeWanted();
454
+ const curNodes = new Set(cy.nodes().map(n => n.id()));
455
+ const curEdges = new Set(cy.edges().map(e => e.id()));
456
+
457
+ let remove = cy.collection();
458
+ cy.nodes().forEach(n => {
459
+ if (!wantNodes.has(n.id())) {
460
+ savedPos.set(n.id(), { x: n.position("x"), y: n.position("y") });
461
+ remove = remove.union(n);
401
462
  }
402
- cy.layout(LAYOUTS[currentLayout]).run();
403
- if (opts.fit) cy.animate({ fit: { padding: 40 }, duration: reduce ? 0 : 350, easing: "ease-out" });
404
- updateStats();
405
- if (opts.then) opts.then();
406
- };
463
+ });
464
+ cy.edges().forEach(e => { if (!wantEdges.has(e.id())) remove = remove.union(e); });
465
+
466
+ const addNodes = [], addEdges = [];
467
+ for (const id of wantNodes) {
468
+ if (curNodes.has(id)) continue;
469
+ const n = nodesById.get(id);
470
+ addNodes.push({ group: "nodes", data: { id, label: shortLabel(n), kind: n.kind, size: nodeSize(id) } });
471
+ }
472
+ for (const [key, e] of wantEdges) {
473
+ if (curEdges.has(key)) continue;
474
+ addEdges.push({ group: "edges", data: { id: key, source: e.src, target: e.dst, kind: e.kind, confidence: e.confidence }, classes: edgeClass(e.confidence) });
475
+ }
476
+
477
+ let added = cy.collection();
478
+ cy.batch(() => {
479
+ if (remove.nonempty()) cy.remove(remove);
480
+ if (addNodes.length) { added = cy.add(addNodes); positionNew(added, curNodes); }
481
+ if (addEdges.length) cy.add(addEdges);
482
+ });
407
483
 
408
- // fade out nodes that are leaving before the graph snaps
409
- const leaving = cy.nodes().filter(n => !after.has(n.id()));
410
- if (!reduce && leaving.length && leaving.length < 80) {
411
- leaving.animate({ style: { opacity: 0 } }, { duration: 150, easing: "ease-in" });
412
- setTimeout(doUpdate, 160);
413
- } else {
414
- doUpdate();
484
+ if (!reduce && added.nonempty()) {
485
+ added.style("opacity", 0);
486
+ added.animate({ style: { opacity: 1 } }, { duration: 240, easing: "ease-out" });
415
487
  }
488
+ if (opts.fit) cy.animate({ fit: { padding: 40 }, duration: reduce ? 0 : 350, easing: "ease-out" });
489
+ updateStats();
490
+ if (opts.then) opts.then();
491
+ }
492
+
493
+ /* Explicit, user-triggered global relayout (layout buttons, expand-all).
494
+ Skips animation past LAYOUT_ANIM_MAX so big graphs snap instead of grind. */
495
+ function runLayout(opts) {
496
+ opts = opts || {};
497
+ const big = cy.nodes().length > LAYOUT_ANIM_MAX;
498
+ const cfg = Object.assign({}, LAYOUTS[currentLayout], {
499
+ animate: (reduce || big) ? false : (currentLayout === "force" ? "end" : true),
500
+ fit: !!opts.fit,
501
+ });
502
+ cy.layout(cfg).run();
416
503
  }
417
504
 
418
505
  function updateStats() {
@@ -497,8 +584,7 @@ function esc(s) { const d = document.createElement("div"); d.textContent = s; re
497
584
  cy.on("mouseover", "node", evt => {
498
585
  const n = evt.target;
499
586
  const hood = n.closedNeighborhood();
500
- cy.elements().not(hood).addClass("dim");
501
- hood.addClass("hot");
587
+ cy.batch(() => { cy.elements().not(hood).addClass("dim"); hood.addClass("hot"); });
502
588
  tip.innerHTML = nodeTip(n);
503
589
  tip.classList.add("show");
504
590
  moveTip(evt);
@@ -506,15 +592,17 @@ cy.on("mouseover", "node", evt => {
506
592
  });
507
593
  cy.on("mouseover", "edge", evt => {
508
594
  const e = evt.target;
509
- cy.elements().not(e.connectedNodes().union(e)).addClass("dim");
510
- e.addClass("hot"); e.connectedNodes().addClass("hot");
595
+ cy.batch(() => {
596
+ cy.elements().not(e.connectedNodes().union(e)).addClass("dim");
597
+ e.addClass("hot"); e.connectedNodes().addClass("hot");
598
+ });
511
599
  tip.innerHTML = edgeTip(e);
512
600
  tip.classList.add("show");
513
601
  moveTip(evt);
514
602
  });
515
603
  cy.on("mousemove", "node, edge", moveTip);
516
604
  cy.on("mouseout", "node, edge", () => {
517
- cy.elements().removeClass("dim hot");
605
+ cy.batch(() => cy.elements().removeClass("dim hot"));
518
606
  tip.classList.remove("show");
519
607
  document.getElementById("cy").style.cursor = "";
520
608
  });
@@ -651,15 +739,15 @@ searchInput.addEventListener("blur", () => setTimeout(closeSug, 150));
651
739
 
652
740
  /* ---------------------------------------------------- toolbar controls */
653
741
  document.getElementById("btn-expand").onclick = () => {
654
- GRAPH.nodes.forEach(n => visible.add(n.id));
655
- GRAPH.nodes.forEach(n => expanded.add(n.id));
656
- refresh({ fit: true });
742
+ GRAPH.nodes.forEach(n => { visible.add(n.id); expanded.add(n.id); });
743
+ refresh(); // add the full delta incrementally (no layout inside)
744
+ runLayout({ fit: true }); // one explicit relayout to arrange the whole graph
657
745
  };
658
746
  document.getElementById("btn-collapse").onclick = () => {
659
747
  visible.clear(); revealedBy.clear(); expanded.clear();
660
748
  GRAPH.nodes.filter(n => n.kind === "FileNode").forEach(n => visible.add(n.id));
661
749
  hidePanel();
662
- refresh({ fit: true });
750
+ refresh({ fit: true }); // file nodes stay put on their saved positions
663
751
  };
664
752
  document.getElementById("btn-fit").onclick = () =>
665
753
  cy.animate({ fit: { padding: 40 }, duration: reduce ? 0 : 350, easing: "ease-out" });
@@ -669,16 +757,18 @@ function setLayout(name) {
669
757
  currentLayout = name;
670
758
  btnForce.classList.toggle("active", name === "force");
671
759
  btnHier.classList.toggle("active", name === "hier");
672
- refresh({ fit: true });
760
+ runLayout({ fit: true }); // relayout existing nodes in place — no rebuild
673
761
  }
674
762
  btnForce.onclick = () => setLayout("force");
675
763
  btnHier.onclick = () => setLayout("hier");
676
764
 
677
765
  const confSlider = document.getElementById("conf"), confVal = document.getElementById("conf-val");
766
+ let confTimer = null;
678
767
  confSlider.addEventListener("input", () => {
679
768
  minConf = parseFloat(confSlider.value);
680
769
  confVal.textContent = minConf.toFixed(2);
681
- refresh();
770
+ clearTimeout(confTimer);
771
+ confTimer = setTimeout(refresh, 90); // debounce; only edges change, nodes never move
682
772
  });
683
773
 
684
774
  /* ------------------------------------------------------ legend filters */
@@ -18,11 +18,49 @@ MCP config::
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import functools
21
22
  from pathlib import Path
22
23
 
23
24
  from mcp.server.fastmcp import FastMCP
24
25
 
25
26
  from sciogen.api import SciogenGraph
27
+ from sciogen.query.engine import NotIndexedError, SymbolNotFound
28
+
29
+
30
+ def _safe(fn):
31
+ """Turn query failures into structured, *terminal* tool results.
32
+
33
+ A raised exception reaches the agent as an opaque error string it cannot
34
+ act on — so it retries, greps, and loops. Returning a typed result with a
35
+ concrete ``next`` step (and near-name ``suggestions`` on a miss) gives the
36
+ agent something to stop on and one clear thing to do. index_codebase is
37
+ left unwrapped — it builds the graph, so it cannot hit these states.
38
+ """
39
+
40
+ @functools.wraps(fn)
41
+ def wrapper(*args, **kwargs):
42
+ try:
43
+ return fn(*args, **kwargs)
44
+ except NotIndexedError as exc:
45
+ return {
46
+ "error": "not_indexed",
47
+ "message": str(exc),
48
+ "next": "Call index_codebase(path) once for this project, then retry.",
49
+ }
50
+ except SymbolNotFound as exc:
51
+ return {
52
+ "found": False,
53
+ "error": "symbol_not_found",
54
+ "message": str(exc),
55
+ "suggestions": exc.suggestions,
56
+ "next": (
57
+ "Retry with one of `suggestions` (they are exact ids), or call "
58
+ "search(query, mode='hybrid') to locate it by meaning. Do NOT fall "
59
+ "back to grep — the graph is indexed, this name just did not match."
60
+ ),
61
+ }
62
+
63
+ return wrapper
26
64
 
27
65
 
28
66
  def build_server(project_root: str | Path) -> FastMCP:
@@ -61,6 +99,7 @@ def build_server(project_root: str | Path) -> FastMCP:
61
99
  }
62
100
 
63
101
  @mcp.tool()
102
+ @_safe
64
103
  def get_symbol(name: str, file: str = "") -> dict:
65
104
  """Look up a symbol by name INSTEAD OF grepping for it. Returns the
66
105
  typed node (kind, file, exact line range, params, docstring) plus its
@@ -69,6 +108,7 @@ def build_server(project_root: str | Path) -> FastMCP:
69
108
  return graph.get_symbol(name, file or None).to_dict()
70
109
 
71
110
  @mcp.tool()
111
+ @_safe
72
112
  def get_callers(symbol: str, depth: int = 1, min_confidence: float = 0.0) -> dict:
73
113
  """Who calls this symbol — use BEFORE changing a signature or behavior
74
114
  so you know every call site without opening files. `depth` walks
@@ -78,12 +118,14 @@ def build_server(project_root: str | Path) -> FastMCP:
78
118
  return graph.get_callers(symbol, depth, min_confidence).to_dict()
79
119
 
80
120
  @mcp.tool()
121
+ @_safe
81
122
  def get_dependencies(file: str) -> dict:
82
123
  """What a file imports — direct files, transitive files, and external
83
124
  modules. Use BEFORE moving code between files or touching imports."""
84
125
  return graph.get_dependencies(file).to_dict()
85
126
 
86
127
  @mcp.tool()
128
+ @_safe
87
129
  def analyze_impact(symbol: str, max_depth: int = 5, min_confidence: float = 0.0) -> dict:
88
130
  """Blast radius of changing this symbol: transitive callers plus
89
131
  direct subclasses, implementors, tests, and mutators. Use BEFORE any
@@ -92,6 +134,7 @@ def build_server(project_root: str | Path) -> FastMCP:
92
134
  return graph.analyze_impact(symbol, max_depth, min_confidence).to_dict()
93
135
 
94
136
  @mcp.tool()
137
+ @_safe
95
138
  def search(query: str, mode: str = "semantic", granularity: str = "", k: int = 10) -> dict:
96
139
  """Find code by meaning ('password hashing logic') INSTEAD OF grep
97
140
  when you don't know exact names. mode='hybrid' also pulls in graph
@@ -100,6 +143,7 @@ def build_server(project_root: str | Path) -> FastMCP:
100
143
  return graph.search(query, mode, granularity or None, k).to_dict()
101
144
 
102
145
  @mcp.tool()
146
+ @_safe
103
147
  def get_diff_impact(unified_diff: str) -> dict:
104
148
  """Impact of a whole change set at once: feed a unified diff (e.g.
105
149
  `git diff` output) and get every touched symbol plus the union blast
@@ -108,6 +152,7 @@ def build_server(project_root: str | Path) -> FastMCP:
108
152
  return graph.get_diff_impact(unified_diff).to_dict()
109
153
 
110
154
  @mcp.tool()
155
+ @_safe
111
156
  def get_context_for_task(description: str, k: int = 15) -> dict:
112
157
  """START HERE for any broad task ('add rate limiting to login'):
113
158
  returns the ranked files and key symbols relevant to the intent, so
@@ -45,7 +45,25 @@ _CACHE_SIZE = 128
45
45
 
46
46
 
47
47
  class SymbolNotFound(LookupError):
48
- """Raised when a named symbol has no match in the graph."""
48
+ """Raised when a named symbol has no match in the graph.
49
+
50
+ Carries ``suggestions`` — near-name matches the caller can retry with, so
51
+ an agent corrects in one hop instead of guessing name variants in a loop.
52
+ """
53
+
54
+ def __init__(self, message: str, suggestions: list[str] | None = None) -> None:
55
+ super().__init__(message)
56
+ self.suggestions = suggestions or []
57
+
58
+
59
+ class NotIndexedError(RuntimeError):
60
+ """Raised when a query runs against an index that was never built.
61
+
62
+ Distinct from :class:`SymbolNotFound` on purpose: an empty index makes
63
+ *every* lookup miss, which is indistinguishable from a genuine not-found
64
+ unless we signal it separately. Without this an agent reads one miss as
65
+ 'the tool knows nothing' and falls back to grep for the whole session.
66
+ """
49
67
 
50
68
 
51
69
  class QueryEngine:
@@ -60,6 +78,11 @@ class QueryEngine:
60
78
 
61
79
  def _cached(self, key: tuple, compute):
62
80
  version = self.meta.index_version
81
+ if version == 0:
82
+ raise NotIndexedError(
83
+ "The knowledge graph is empty — this project has not been indexed yet. "
84
+ "Call index_codebase(path) once, then retry this query."
85
+ )
63
86
  if version != self._cache_version:
64
87
  self._cache.clear()
65
88
  self._cache_version = version
@@ -78,7 +101,8 @@ class QueryEngine:
78
101
  rows = self.graph.find_by_name(name, file)
79
102
  if not rows:
80
103
  raise SymbolNotFound(
81
- f"No symbol named '{name}'" + (f" in {file}" if file else "") + " in the index."
104
+ f"No symbol named '{name}'" + (f" in {file}" if file else "") + " in the index.",
105
+ suggestions=self.meta.suggest_symbol_ids(name),
82
106
  )
83
107
  # Exact qualname beats suffix match; shorter qualname beats deeper nesting.
84
108
  rows.sort(key=lambda r: (r["name"] != name, len(r["name"])))
@@ -160,7 +184,10 @@ class QueryEngine:
160
184
 
161
185
  def _get_dependencies(self, file: str, max_depth: int) -> DependencyInfo:
162
186
  if self.graph.get_node(file) is None:
163
- raise SymbolNotFound(f"File '{file}' is not in the index.")
187
+ raise SymbolNotFound(
188
+ f"File '{file}' is not in the index.",
189
+ suggestions=self.meta.suggest_files(file),
190
+ )
164
191
  direct = self.graph.out_edges(file, EdgeKind.IMPORTS)
165
192
  reachable = self.graph.traverse(
166
193
  [file], EdgeKind.IMPORTS, direction="out", max_depth=max_depth
@@ -199,6 +199,47 @@ class MetaStore:
199
199
  table.files = {row[0] for row in self.conn.execute("SELECT path FROM files")}
200
200
  return table
201
201
 
202
+ def suggest_symbol_ids(self, name: str, limit: int = 5) -> list[str]:
203
+ """Best-effort near-name matches for a symbol miss.
204
+
205
+ Narrows with a SQL ``LIKE`` on the simple name before ranking with
206
+ difflib, so a typo ('Rollng' -> 'Rolling') or a partial name still
207
+ surfaces the real qualname the agent should retry with — instead of a
208
+ bare 'not found' that sends it back to grep.
209
+ """
210
+ import difflib
211
+
212
+ frag = name.rsplit(".", 1)[-1]
213
+ rows = self._like_symbols(f"%{frag}%")
214
+ if not rows and len(frag) >= 3:
215
+ rows = self._like_symbols(f"{frag[:3]}%")
216
+ if not rows:
217
+ return []
218
+ rows.sort(
219
+ key=lambda r: difflib.SequenceMatcher(None, frag.lower(), r[1].lower()).ratio(),
220
+ reverse=True,
221
+ )
222
+ return [rid for rid, _simple in rows[:limit]]
223
+
224
+ def _like_symbols(self, pattern: str, cap: int = 500) -> list[tuple[str, str]]:
225
+ return self.conn.execute(
226
+ "SELECT id, simple_name FROM symbols WHERE simple_name LIKE ? LIMIT ?",
227
+ (pattern, cap),
228
+ ).fetchall()
229
+
230
+ def suggest_files(self, path: str, limit: int = 5) -> list[str]:
231
+ """Near-path matches for a file miss, ranked by difflib."""
232
+ import difflib
233
+
234
+ base = path.replace("\\", "/").rsplit("/", 1)[-1]
235
+ rows = self.conn.execute(
236
+ "SELECT path FROM files WHERE path LIKE ? LIMIT 500", (f"%{base}%",)
237
+ ).fetchall()
238
+ if not rows:
239
+ rows = self.conn.execute("SELECT path FROM files LIMIT 2000").fetchall()
240
+ paths = [r[0] for r in rows]
241
+ return difflib.get_close_matches(path.replace("\\", "/"), paths, n=limit, cutoff=0.3)
242
+
202
243
  def replace_symbols(self, file: str, rows: list[tuple[str, str, str]]) -> None:
203
244
  """rows: (id, simple_name, kind)."""
204
245
  self.conn.execute("DELETE FROM symbols WHERE file = ?", (file,))
@@ -325,6 +325,43 @@
325
325
  footer.site { border-top: 1px solid var(--line-soft); padding: 36px 24px 56px; }
326
326
  .foot-in { max-width: var(--maxw); margin: 0 auto; display: flex; flex-wrap: wrap; gap: 16px; justify-content: space-between; color: var(--fg-3); font-size: 13px; }
327
327
  .foot-in a:hover { color: var(--accent); }
328
+
329
+ /* ---------- mobile refinements ---------- */
330
+ @media (max-width: 640px) {
331
+ .nav-in { height: 56px; padding: 0 18px; }
332
+ .nav-right { gap: 12px; }
333
+ .nav-cta { padding: 7px 12px; font-size: 12px; }
334
+ .icon-btn { width: 34px; height: 34px; }
335
+
336
+ .shell { padding: 0 18px; }
337
+
338
+ /* TOC: a compact wrapping row of links above the content, not a tall list */
339
+ aside.toc { padding: 18px 0; }
340
+ .toc .group { margin-bottom: 14px; }
341
+ .toc .group-label { margin-bottom: 6px; }
342
+ .toc .group > a { display: inline-block; border-left: 0; padding: 5px 10px; }
343
+
344
+ main.doc { padding: 32px 0 90px; }
345
+ .doc .lede { font-size: 1.06rem; max-width: 100%; }
346
+ .doc section.block { padding-top: 60px; }
347
+ .doc p, .doc ul, .doc ol { max-width: 100%; }
348
+
349
+ .callout { padding: 20px 20px; }
350
+ .code pre { padding: 16px; font-size: 12.5px; }
351
+ .dd-inner { padding: 0 16px; }
352
+ .dd-head { padding: 14px 16px; }
353
+
354
+ /* prev/next stack instead of squeezing side by side */
355
+ .doc-nav { flex-direction: column; margin-top: 60px; }
356
+ .doc-nav a { max-width: 100%; }
357
+ .doc-nav a.next { text-align: left; margin-left: 0; }
358
+ }
359
+
360
+ @media (max-width: 400px) {
361
+ .shell { padding: 0 14px; }
362
+ .brand .crumb { display: none; }
363
+ .nav-right { gap: 8px; }
364
+ }
328
365
  </style>
329
366
  </head>
330
367
  <body>
@@ -367,6 +404,10 @@
367
404
  <a href="#cli">CLI reference</a>
368
405
  <a href="#configuration">Configuration</a>
369
406
  </div>
407
+ <div class="group">
408
+ <div class="group-label">Community</div>
409
+ <a href="#contributing">Contributing</a>
410
+ </div>
370
411
  </aside>
371
412
 
372
413
  <main class="doc">
@@ -569,7 +610,7 @@
569
610
  <div class="pstage"><div class="pn">05 · DIFF</div><div class="pt">SHA256 differ</div><div class="pd">Only changed files re-run next time</div></div>
570
611
  </div>
571
612
 
572
- <h3 class="reveal">The full system, interactive</h3>
613
+ <h3 class="reveal">The full system</h3>
573
614
  <p class="reveal">Everything on one map — the indexing pipeline, the triple store, and the query side. Click any component to see what it does, or play a flow to watch how data moves.</p>
574
615
 
575
616
  <div class="arch reveal" id="archmap">
@@ -817,10 +858,63 @@
817
858
  <tr><td><code>SCIOGEN_DATA_DIR</code></td><td>Override the index location (default <code>&lt;project&gt;/.sciogen</code>)</td></tr>
818
859
  </tbody>
819
860
  </table></div>
861
+ </section>
862
+
863
+ <!-- ============================ CONTRIBUTING ============================ -->
864
+ <section class="block" id="contributing">
865
+ <h2 class="reveal">Contributing <a class="hash" href="#contributing">#</a></h2>
866
+ <p class="reveal">sciogen is open source and contributions are welcome — bug reports, language-normalizer improvements, docs, and features all move it forward. The complete guide lives in <a href="https://github.com/ayanbag/sciogen/blob/master/CONTRIBUTING.md" target="_blank" rel="noopener">CONTRIBUTING.md</a>; the essentials are below.</p>
867
+
868
+ <h3 class="reveal">Set up a dev environment</h3>
869
+ <p class="reveal">sciogen targets <b>Python 3.10+</b> and builds with hatchling. Install it <b>editable</b> (<code>-e</code>) so source edits — including the version and CLI banner — take effect immediately, with no reinstall:</p>
870
+ <div class="code reveal"><span class="lang">shell</span><button class="copy" type="button" data-copy='git clone https://github.com/ayanbag/sciogen && cd sciogen && pip install -e ".[dev,embeddings]"' aria-label="Copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>
871
+ <pre><span class="g">$</span> git clone https://github.com/ayanbag/sciogen
872
+ <span class="g">$</span> cd sciogen
873
+ <span class="g">$</span> pip install -e ".[dev,embeddings]"</pre></div>
874
+ <p class="reveal">Then confirm your environment is live — both must print the <b>same</b> version; if they disagree you're running a stale install:</p>
875
+ <div class="code reveal"><span class="lang">shell</span><button class="copy" type="button" data-copy='python -c "import sciogen; print(sciogen.__version__)" && python -m sciogen --version' aria-label="Copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>
876
+ <pre><span class="g">$</span> python -c "import sciogen; print(sciogen.__version__)"
877
+ <span class="g">$</span> python -m sciogen --version</pre></div>
878
+
879
+ <h3 class="reveal">Run the tests</h3>
880
+ <p class="reveal">Every change should keep the suite green. New behavior needs a test; a bug fix should add a regression test that fails before and passes after.</p>
881
+ <div class="code reveal"><span class="lang">shell</span><button class="copy" type="button" data-copy="pytest" aria-label="Copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>
882
+ <pre><span class="g">$</span> pytest <span class="mut"># full suite</span>
883
+ <span class="g">$</span> pytest -p no:randomly <span class="mut"># deterministic order</span></pre></div>
884
+
885
+ <div class="callout reveal">
886
+ <span class="t">What belongs in sciogen</span>
887
+ <p>sciogen is a static index, not a generative tool. A few constraints are non-negotiable, and a PR that breaks one will be asked to change: <b>no LLM calls</b> anywhere; <b>no token counting or context-window logic</b>; query methods return <b>typed nodes, never raw source</b>; the pipeline <b>writes every edge</b> and confidence is filtered at query time, not at write time. Language-specific logic lives in exactly one place — the <b>AST normalizer</b> — so everything downstream stays language-agnostic.</p>
888
+ </div>
889
+
890
+ <h3 class="reveal">Branch off master</h3>
891
+ <p class="reveal">Work on a branch, never on <code>master</code> itself. Start from an up-to-date <code>master</code> and name the branch <code>type/short-description</code> — a prefix that says what the change is, then a few kebab-case words:</p>
892
+ <div class="code reveal"><span class="lang">shell</span><button class="copy" type="button" data-copy="git checkout master && git pull && git checkout -b fix/login-caller-resolution" aria-label="Copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>
893
+ <pre><span class="g">$</span> git checkout master
894
+ <span class="g">$</span> git pull <span class="mut"># start from an up-to-date master</span>
895
+ <span class="g">$</span> git checkout -b <span class="hi">fix/login-caller-resolution</span></pre></div>
896
+ <div class="tbl-wrap reveal"><table>
897
+ <thead><tr><th>Prefix</th><th>For</th></tr></thead>
898
+ <tbody>
899
+ <tr><td><code>fix/</code></td><td>A bug fix</td></tr>
900
+ <tr><td><code>feat/</code></td><td>A new feature or capability</td></tr>
901
+ <tr><td><code>docs/</code></td><td>Documentation only</td></tr>
902
+ <tr><td><code>refactor/</code></td><td>Internal change, no behavior difference</td></tr>
903
+ <tr><td><code>test/</code></td><td>Adding or reworking tests</td></tr>
904
+ <tr><td><code>chore/</code></td><td>Tooling, CI, packaging, dependencies</td></tr>
905
+ </tbody>
906
+ </table></div>
907
+ <p class="reveal">Keep one branch to one logical change — small, focused branches review faster and revert cleanly. If <code>master</code> moves while you work, rebase on top of it so history stays linear:</p>
908
+ <div class="code reveal"><span class="lang">shell</span><button class="copy" type="button" data-copy="git fetch origin && git rebase origin/master" aria-label="Copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg></button>
909
+ <pre><span class="g">$</span> git fetch origin
910
+ <span class="g">$</span> git rebase origin/master</pre></div>
911
+
912
+ <h3 class="reveal">Versioning</h3>
913
+ <p class="reveal">The version is declared in <b>two places that must stay in sync</b>: <code>version</code> in <code>pyproject.toml</code> (the packaged version on PyPI) and <code>__version__</code> in <code>sciogen/__init__.py</code> (imported by the CLI banner). Bump <b>both</b> to the same value — the publish workflow asserts the release tag matches the package version, so a mismatch fails the release.</p>
820
914
 
821
915
  <div class="doc-nav reveal">
822
916
  <a href="index.html"><span class="lbl">← Back</span><div class="ttl">sciogen home</div></a>
823
- <a class="next" href="https://github.com/ayanbag/sciogen" target="_blank" rel="noopener"><span class="lbl">Source →</span><div class="ttl">GitHub repository</div></a>
917
+ <a class="next" href="https://github.com/ayanbag/sciogen/blob/master/CONTRIBUTING.md" target="_blank" rel="noopener"><span class="lbl">Full guide →</span><div class="ttl">CONTRIBUTING.md</div></a>
824
918
  </div>
825
919
  </section>
826
920
  </main>
@@ -369,6 +369,55 @@
369
369
  transition: all .3s var(--ease); z-index: 100;
370
370
  }
371
371
  .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
372
+
373
+ /* ---------- mobile refinements ---------- */
374
+ @media (max-width: 640px) {
375
+ .wrap { padding: 0 18px; }
376
+
377
+ /* nav: tighten so brand + theme + two pills never overflow narrow phones */
378
+ .nav-in { height: 56px; }
379
+ .nav-links { gap: 12px; }
380
+ .nav-cta { padding: 7px 12px; font-size: 12px; }
381
+ .icon-btn { width: 34px; height: 34px; }
382
+
383
+ /* hero: shorter, no forced viewport height, text spans full column */
384
+ .hero { min-height: 0; }
385
+ .hero-in { padding: 46px 0 64px; }
386
+ .hero h1 { max-width: 100%; font-weight: 680; }
387
+ .hero p.lede { margin-top: 20px; max-width: 100%; }
388
+ .hero-actions { margin-top: 30px; gap: 12px; }
389
+ /* stack the install command + primary button full-width */
390
+ .hero-actions .cmd { width: 100%; justify-content: space-between; }
391
+ .hero-actions .btn-primary { width: 100%; justify-content: center; }
392
+ .chips { margin-top: 32px; gap: 12px 22px; }
393
+
394
+ /* sections: reduce the large vertical rhythm on small screens */
395
+ section { padding: 60px 0; }
396
+ .why-grid, .pipe, .caps, .conf-grid, .duo, .stores, .demo-wrap { margin-top: 40px; }
397
+ .sec-head p { font-size: 1.02rem; }
398
+
399
+ /* confidence rows wrap instead of squeezing the label */
400
+ .conf-row { flex-wrap: wrap; gap: 8px 14px; }
401
+ .conf-svg { width: 72px; }
402
+ .conf-row .cval { width: auto; }
403
+
404
+ .use { padding: 24px; }
405
+ .cost { padding: 22px; }
406
+ .store { padding: 22px; }
407
+
408
+ /* cta command + button also stack full-width */
409
+ .cta .hero-actions .cmd,
410
+ .cta .hero-actions .btn-primary { width: 100%; }
411
+ }
412
+
413
+ @media (max-width: 380px) {
414
+ .wrap { padding: 0 14px; }
415
+ .brand { font-size: 15px; }
416
+ .nav-links { gap: 8px; }
417
+ /* drop the Docs pill on the tightest screens — it lives in the footer too */
418
+ .nav-links .nav-cta[href="docs.html"] { display: none; }
419
+ .cmd { font-size: 13.5px; padding: 12px 14px; }
420
+ }
372
421
  </style>
373
422
  </head>
374
423
  <body>
@@ -0,0 +1,42 @@
1
+ """MCP layer: query failures come back as structured, terminal results.
2
+
3
+ The wrappers must never let a query exception escape as an opaque error
4
+ string — an agent cannot act on that and falls back to grep or loops. Each
5
+ failure maps to a dict with a concrete ``next`` step (and ``suggestions`` on a
6
+ miss) so the agent has one clear thing to do.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from sciogen.mcp.server import _safe
12
+ from sciogen.query.engine import NotIndexedError, SymbolNotFound
13
+
14
+
15
+ def test_safe_maps_not_indexed_to_structured_result():
16
+ @_safe
17
+ def tool():
18
+ raise NotIndexedError("empty")
19
+
20
+ result = tool()
21
+ assert result["error"] == "not_indexed"
22
+ assert "index_codebase" in result["next"]
23
+
24
+
25
+ def test_safe_maps_symbol_miss_with_suggestions():
26
+ @_safe
27
+ def tool():
28
+ raise SymbolNotFound("no such symbol", suggestions=["pkg/mod.py:Real.name"])
29
+
30
+ result = tool()
31
+ assert result["found"] is False
32
+ assert result["error"] == "symbol_not_found"
33
+ assert result["suggestions"] == ["pkg/mod.py:Real.name"]
34
+ assert "grep" in result["next"].lower() # explicitly steers off grep
35
+
36
+
37
+ def test_safe_passes_success_through_unchanged():
38
+ @_safe
39
+ def tool():
40
+ return {"node": {"id": "x"}}
41
+
42
+ assert tool() == {"node": {"id": "x"}}
@@ -111,6 +111,39 @@ def test_unknown_symbol_raises(indexed_project):
111
111
  indexed_project.get_symbol("does_not_exist_anywhere")
112
112
 
113
113
 
114
+ def test_symbol_miss_carries_suggestions(indexed_project):
115
+ """A near-miss name surfaces the real qualname to retry with, so an agent
116
+ corrects in one hop instead of looping over grep."""
117
+ from sciogen.query.engine import SymbolNotFound
118
+
119
+ with pytest.raises(SymbolNotFound) as exc:
120
+ indexed_project.get_symbol("logn") # typo for AuthService.login
121
+ assert "src/auth/service.py:AuthService.login" in exc.value.suggestions
122
+
123
+
124
+ def test_file_miss_carries_path_suggestions(indexed_project):
125
+ from sciogen.query.engine import SymbolNotFound
126
+
127
+ with pytest.raises(SymbolNotFound) as exc:
128
+ indexed_project.get_dependencies("src/auth/servcie.py") # typo
129
+ assert "src/auth/service.py" in exc.value.suggestions
130
+
131
+
132
+ def test_query_on_unindexed_graph_signals_not_indexed(tmp_path: Path):
133
+ """An empty index must raise NotIndexedError, not a generic miss —
134
+ otherwise every lookup looks like 'symbol not found' and an agent gives up
135
+ on the tools entirely."""
136
+ from sciogen.query.engine import NotIndexedError
137
+
138
+ write_sample_project(tmp_path)
139
+ graph = sciogen.open(tmp_path) # constructed but never indexed
140
+ try:
141
+ with pytest.raises(NotIndexedError):
142
+ graph.get_symbol("AuthService.login")
143
+ finally:
144
+ graph.close()
145
+
146
+
114
147
  # ------------------------------------------------------------- incremental
115
148
 
116
149
  def test_incremental_reindex(tmp_path: Path):
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes