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.
- {sciogen-0.1.2 → sciogen-0.1.4}/.gitignore +4 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/PKG-INFO +7 -1
- {sciogen-0.1.2 → sciogen-0.1.4}/README.md +6 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/pyproject.toml +1 -1
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/__init__.py +1 -1
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/explore/template.html +132 -42
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/mcp/server.py +45 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/query/engine.py +30 -3
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/stores/meta.py +41 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/site/docs.html +96 -2
- {sciogen-0.1.2 → sciogen-0.1.4}/site/index.html +49 -0
- sciogen-0.1.4/tests/test_mcp_server.py +42 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_pipeline_and_query.py +33 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/LICENSE +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/docs/architecture.md +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/docs/cli.md +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/docs/mcp.md +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/docs/quickstart.md +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/docs/schema.md +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/__main__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/api.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/banner.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/console.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/explore/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/cli/main.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/config.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/discovery.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/embed/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/embed/hashing.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/embed/nomic.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/index/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/index/pipeline.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/ir.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/mcp/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/base.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/generic.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/javascript.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/normalize/python.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/parsing/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/parsing/languages.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/parsing/parser.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/query/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/query/results.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/resolve/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/resolve/resolver.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/stores/__init__.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/stores/graph.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/sciogen/stores/vectors.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/site/public/icon.png +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/site/public/logo.png +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/tests/conftest.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_cli.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_discovery.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_graph_store.py +0 -0
- {sciogen-0.1.2 → sciogen-0.1.4}/tests/test_normalize_python.py +0 -0
- {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.
|
|
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>
|
|
@@ -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
|
-
|
|
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
|
-
/*
|
|
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()
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
388
|
-
const
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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.
|
|
510
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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><project>/.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">
|
|
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
|
|
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
|