stitchgraph 3.27.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stitchgraph/__init__.py +85 -0
- stitchgraph/adapters/__init__.py +4 -0
- stitchgraph/adapters/_guard.py +47 -0
- stitchgraph/adapters/cli.py +219 -0
- stitchgraph/adapters/mcp.py +88 -0
- stitchgraph/adapters/render.py +56 -0
- stitchgraph/adapters/report.py +99 -0
- stitchgraph/core/__init__.py +12 -0
- stitchgraph/core/_scc.py +60 -0
- stitchgraph/core/algebra.py +167 -0
- stitchgraph/core/config.py +104 -0
- stitchgraph/core/coverage_query.py +194 -0
- stitchgraph/core/coverage_scaffold.py +258 -0
- stitchgraph/core/dataloop.py +49 -0
- stitchgraph/core/entrypoints.py +137 -0
- stitchgraph/core/envelope.py +156 -0
- stitchgraph/core/extract/__init__.py +60 -0
- stitchgraph/core/extract/_testfile.py +22 -0
- stitchgraph/core/extract/python.py +1808 -0
- stitchgraph/core/extract/treesitter.py +3072 -0
- stitchgraph/core/gitrisk.py +98 -0
- stitchgraph/core/graphdiff.py +127 -0
- stitchgraph/core/model.py +140 -0
- stitchgraph/core/modes.py +411 -0
- stitchgraph/core/operations.py +2107 -0
- stitchgraph/core/reach.py +293 -0
- stitchgraph/core/resolve/__init__.py +140 -0
- stitchgraph/core/resolve/events.py +124 -0
- stitchgraph/core/resolve/express.py +144 -0
- stitchgraph/core/resolve/html.py +113 -0
- stitchgraph/core/resolve/jedi_resolver.py +96 -0
- stitchgraph/core/resolve/jsfetch.py +145 -0
- stitchgraph/core/resolve/orm.py +111 -0
- stitchgraph/core/resolve/routes.py +118 -0
- stitchgraph/core/resolve/spring.py +117 -0
- stitchgraph/core/resolve/sql.py +139 -0
- stitchgraph/core/runtime.py +192 -0
- stitchgraph/core/similar.py +301 -0
- stitchgraph/core/spectral.py +252 -0
- stitchgraph/core/store.py +882 -0
- stitchgraph/core/structure.py +570 -0
- stitchgraph/core/structure_bash.py +584 -0
- stitchgraph/core/structure_common.py +120 -0
- stitchgraph/core/structure_cpp.py +928 -0
- stitchgraph/core/structure_csharp.py +908 -0
- stitchgraph/core/structure_go.py +665 -0
- stitchgraph/core/structure_java.py +752 -0
- stitchgraph/core/structure_js.py +705 -0
- stitchgraph/core/structure_php.py +752 -0
- stitchgraph/core/structure_ruby.py +649 -0
- stitchgraph/core/structure_rust.py +798 -0
- stitchgraph/core/watch.py +43 -0
- stitchgraph-3.27.1.dist-info/METADATA +316 -0
- stitchgraph-3.27.1.dist-info/RECORD +57 -0
- stitchgraph-3.27.1.dist-info/WHEEL +4 -0
- stitchgraph-3.27.1.dist-info/entry_points.txt +3 -0
- stitchgraph-3.27.1.dist-info/licenses/LICENSE +21 -0
stitchgraph/__init__.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""stitchgraph — local-first code-intelligence (library + CLI + MCP).
|
|
2
|
+
|
|
3
|
+
The library API is the core: import the operations directly.
|
|
4
|
+
|
|
5
|
+
from stitchgraph import Store, find_symbol
|
|
6
|
+
with Store("graph.db") as s:
|
|
7
|
+
print(find_symbol(s, "UserService"))
|
|
8
|
+
|
|
9
|
+
The CLI (`stitchgraph ...`) and the MCP server are thin adapters over these same
|
|
10
|
+
operations — same names, same params (design §3).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import PackageNotFoundError
|
|
16
|
+
from importlib.metadata import version as _version
|
|
17
|
+
|
|
18
|
+
from .core import (
|
|
19
|
+
Edge,
|
|
20
|
+
Node,
|
|
21
|
+
NodeKind,
|
|
22
|
+
Provenance,
|
|
23
|
+
Relation,
|
|
24
|
+
Result,
|
|
25
|
+
Store,
|
|
26
|
+
Urgency,
|
|
27
|
+
ok,
|
|
28
|
+
refuse,
|
|
29
|
+
registry,
|
|
30
|
+
)
|
|
31
|
+
from .core.operations import (
|
|
32
|
+
co_change,
|
|
33
|
+
coverage_drift,
|
|
34
|
+
feature_map,
|
|
35
|
+
find_chokepoints,
|
|
36
|
+
find_core,
|
|
37
|
+
find_coupling,
|
|
38
|
+
find_gaps,
|
|
39
|
+
find_holes,
|
|
40
|
+
find_modes,
|
|
41
|
+
find_outlier_tests,
|
|
42
|
+
find_similar,
|
|
43
|
+
find_stale,
|
|
44
|
+
find_subsystems,
|
|
45
|
+
find_symbol,
|
|
46
|
+
get_callees,
|
|
47
|
+
get_callers,
|
|
48
|
+
get_matrix,
|
|
49
|
+
graph_diff,
|
|
50
|
+
impact_of,
|
|
51
|
+
ingest_trace,
|
|
52
|
+
orient,
|
|
53
|
+
redundant_tests,
|
|
54
|
+
reindex,
|
|
55
|
+
risk,
|
|
56
|
+
runtime_risk,
|
|
57
|
+
scaffold_coverage,
|
|
58
|
+
scan,
|
|
59
|
+
select_tests,
|
|
60
|
+
summarize_subsystem,
|
|
61
|
+
test_order,
|
|
62
|
+
trace_path,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Single source of truth: the installed distribution metadata (driven by
|
|
66
|
+
# pyproject.toml's version). A hardcoded literal drifted stale across 1.0.3→1.0.5;
|
|
67
|
+
# deriving it here keeps `stitchgraph.__version__` and `stitchgraph --version` in sync.
|
|
68
|
+
try:
|
|
69
|
+
__version__ = _version("stitchgraph")
|
|
70
|
+
except PackageNotFoundError: # pragma: no cover - source tree without installed dist
|
|
71
|
+
__version__ = "0.0.0+unknown"
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
# types
|
|
75
|
+
"Store", "Result", "Node", "Edge", "NodeKind", "Relation",
|
|
76
|
+
"Provenance", "Urgency", "ok", "refuse", "registry",
|
|
77
|
+
# operations (the public API)
|
|
78
|
+
"find_symbol", "get_callers", "get_callees", "find_holes", "find_stale",
|
|
79
|
+
"orient", "impact_of", "trace_path", "scan", "reindex", "get_matrix", "risk",
|
|
80
|
+
"ingest_trace", "find_similar", "summarize_subsystem", "graph_diff",
|
|
81
|
+
"find_chokepoints", "find_subsystems", "find_modes", "scaffold_coverage",
|
|
82
|
+
"select_tests", "co_change", "find_coupling",
|
|
83
|
+
"find_gaps", "test_order", "redundant_tests", "find_core",
|
|
84
|
+
"feature_map", "find_outlier_tests", "runtime_risk", "coverage_drift",
|
|
85
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Shared adapter guard: refuse queries against a missing / never-indexed DB.
|
|
2
|
+
|
|
3
|
+
`Store(path)` creates an empty database on open, so a mispointed adapter
|
|
4
|
+
(wrong cwd, typo'd --db, an MCP client launching the server from an arbitrary
|
|
5
|
+
directory) would answer every query from a vacuum at full confidence — `orient`
|
|
6
|
+
returns ok/1.0 with zero nodes and an LLM concludes "tiny repo, nothing dead,
|
|
7
|
+
all green". That is the exact failure mode the envelope contract exists to
|
|
8
|
+
prevent (review 2026-07-03, F2b). Query operations therefore refuse when the
|
|
9
|
+
DB file doesn't exist or has never been indexed; only `reindex` may create one.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sqlite3
|
|
16
|
+
|
|
17
|
+
from ..core.envelope import Result, refuse
|
|
18
|
+
from ..core.store import Store
|
|
19
|
+
|
|
20
|
+
# Operations allowed to open a DB that doesn't exist yet (they populate it).
|
|
21
|
+
_CREATES_DB = {"reindex"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def open_store(db: str, op_name: str) -> tuple[Store | None, Result | None]:
|
|
25
|
+
"""Open the index DB for an operation. Returns (store, None) on success,
|
|
26
|
+
(None, refusal Result) when the op is a query and the DB is absent, empty,
|
|
27
|
+
or unopenable — never silently creating an index to answer from."""
|
|
28
|
+
creates = op_name in _CREATES_DB
|
|
29
|
+
if not creates and db != ":memory:" and not os.path.exists(db):
|
|
30
|
+
return None, refuse(
|
|
31
|
+
f"index database {db!r} does not exist — run `reindex` first, or point "
|
|
32
|
+
"--db (or STITCHGRAPH_DB for the MCP server) at the built index")
|
|
33
|
+
try:
|
|
34
|
+
store = Store(db)
|
|
35
|
+
except (sqlite3.Error, OSError) as exc:
|
|
36
|
+
# A db path that can't back a database (a directory, FIFO, device, or
|
|
37
|
+
# unwritable location) must return an envelope, not a raw sqlite
|
|
38
|
+
# traceback (panel R12).
|
|
39
|
+
return None, refuse(f"cannot open index database {db!r}: {exc}")
|
|
40
|
+
if not creates and not store.get_meta("root"):
|
|
41
|
+
# The file exists but no reindex ever recorded a root: an empty shell
|
|
42
|
+
# (typically created by a previous mispointed open) — refusing beats
|
|
43
|
+
# confidently reporting an empty codebase.
|
|
44
|
+
store.close()
|
|
45
|
+
return None, refuse(
|
|
46
|
+
f"index database {db!r} holds no indexed project — run `reindex` first")
|
|
47
|
+
return store, None
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""CLI adapter (design §3). Built by introspecting core.operations so the CLI
|
|
2
|
+
maps 1:1 onto the library API — same names (kebab-cased), same params.
|
|
3
|
+
|
|
4
|
+
Typer is an optional dependency (`pip install stitchgraph[cli]`); imported lazily
|
|
5
|
+
so `import stitchgraph` never requires it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import json as _json
|
|
12
|
+
import sqlite3
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..core.operations import Operation, registry
|
|
16
|
+
from ..core.store import Store
|
|
17
|
+
from ._guard import open_store
|
|
18
|
+
from .render import render_text
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _require_typer():
|
|
22
|
+
try:
|
|
23
|
+
import typer
|
|
24
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
25
|
+
raise SystemExit(
|
|
26
|
+
"The CLI needs Typer. Install it with: pip install 'stitchgraph[cli]'"
|
|
27
|
+
) from exc
|
|
28
|
+
return typer
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_app():
|
|
32
|
+
typer = _require_typer()
|
|
33
|
+
# no_args_is_help: bare `stitchgraph` shows help (not a silent exit) — the
|
|
34
|
+
# invoke_without_command callback below would otherwise swallow it (issue #19).
|
|
35
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True,
|
|
36
|
+
help="stitchgraph — code intelligence")
|
|
37
|
+
|
|
38
|
+
def _version_callback(value: bool) -> None:
|
|
39
|
+
if not value:
|
|
40
|
+
return
|
|
41
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
42
|
+
|
|
43
|
+
from ..core.extract import treesitter as ts
|
|
44
|
+
try:
|
|
45
|
+
ver = version("stitchgraph")
|
|
46
|
+
except PackageNotFoundError: # pragma: no cover - not installed as a dist
|
|
47
|
+
ver = "unknown"
|
|
48
|
+
typer.echo(f"stitchgraph {ver}")
|
|
49
|
+
# The install model is version-keyed (bundled vs download grammar line, #12),
|
|
50
|
+
# so report the active tree-sitter-language-pack line too — exactly what a bug
|
|
51
|
+
# report needs (issue #19).
|
|
52
|
+
backend = ts.grammar_backend()
|
|
53
|
+
if backend.get("installed"):
|
|
54
|
+
typer.echo(f"tree-sitter-language-pack {backend['version']} [{backend['model']}]")
|
|
55
|
+
else:
|
|
56
|
+
typer.echo("tree-sitter-language-pack not installed (Python-only extraction)")
|
|
57
|
+
raise typer.Exit()
|
|
58
|
+
|
|
59
|
+
@app.callback(invoke_without_command=True)
|
|
60
|
+
def _root(
|
|
61
|
+
version: bool = typer.Option(
|
|
62
|
+
False, "--version", callback=_version_callback, is_eager=True,
|
|
63
|
+
help="Show the stitchgraph version (and active grammar line) and exit."),
|
|
64
|
+
) -> None:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
for op in registry():
|
|
68
|
+
app.command(name=op.name.replace("_", "-"), help=op.summary)(_make_command(typer, op))
|
|
69
|
+
|
|
70
|
+
@app.command(name="watch", help="Re-index on file changes (Ctrl-C to stop).")
|
|
71
|
+
def _watch(
|
|
72
|
+
path: str = typer.Argument(".", help="repo root to watch"),
|
|
73
|
+
db: str = typer.Option("stitchgraph.db", help="index database path"),
|
|
74
|
+
interval: float = typer.Option(2.0, help="poll interval (seconds)"),
|
|
75
|
+
) -> None:
|
|
76
|
+
import time
|
|
77
|
+
|
|
78
|
+
from ..core import operations as ops
|
|
79
|
+
from ..core.watch import changed, snapshot
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
store = Store(db)
|
|
83
|
+
except (sqlite3.Error, OSError) as exc:
|
|
84
|
+
typer.echo(f"cannot open index database {db!r}: {exc}")
|
|
85
|
+
raise typer.Exit(1) from exc
|
|
86
|
+
with store:
|
|
87
|
+
typer.echo(ops.reindex(store, path).meta)
|
|
88
|
+
state = snapshot(path)
|
|
89
|
+
typer.echo(f"watching {path} (every {interval}s)…")
|
|
90
|
+
try:
|
|
91
|
+
while True:
|
|
92
|
+
time.sleep(interval)
|
|
93
|
+
new = snapshot(path)
|
|
94
|
+
if changed(state, new):
|
|
95
|
+
state = new
|
|
96
|
+
typer.echo(f"change detected — reindexing… {ops.reindex(store, path).meta}")
|
|
97
|
+
except KeyboardInterrupt:
|
|
98
|
+
typer.echo("stopped")
|
|
99
|
+
|
|
100
|
+
@app.command(name="report", help="Full Markdown report (orientation + issues + risk).")
|
|
101
|
+
def _report(
|
|
102
|
+
db: str = typer.Option("stitchgraph.db", help="index database path"),
|
|
103
|
+
repo: str | None = typer.Option(
|
|
104
|
+
None, help="repo root for git risk (default: the indexed root in the DB)"),
|
|
105
|
+
) -> None:
|
|
106
|
+
from .report import build_report
|
|
107
|
+
typer.echo(build_report(db, repo))
|
|
108
|
+
|
|
109
|
+
@app.command(name="doctor",
|
|
110
|
+
help="Check tree-sitter grammar availability (polyglot offline-readiness).")
|
|
111
|
+
def _doctor(
|
|
112
|
+
strict: bool = typer.Option(
|
|
113
|
+
False, "--strict", help="exit non-zero if any supported grammar can't load"),
|
|
114
|
+
) -> None:
|
|
115
|
+
from ..core.extract import treesitter as ts
|
|
116
|
+
backend = ts.grammar_backend()
|
|
117
|
+
if not backend.get("installed"):
|
|
118
|
+
typer.echo("tree-sitter not installed — polyglot extraction is OFF "
|
|
119
|
+
"(Python still works). Install: pip install 'stitchgraph[treesitter]'")
|
|
120
|
+
raise typer.Exit(1 if strict else 0)
|
|
121
|
+
typer.echo(f"tree-sitter-language-pack {backend['version']} [{backend['model']}]")
|
|
122
|
+
if "cache_dir" in backend:
|
|
123
|
+
typer.echo(f" grammar cache: {backend['cache_dir']}")
|
|
124
|
+
all_ok, rows = ts.grammar_status()
|
|
125
|
+
for lang, ok, detail in rows:
|
|
126
|
+
typer.echo(f" {'ok ' if ok else 'FAIL'} {lang:12} {detail}")
|
|
127
|
+
n_ok = sum(1 for _, ok, _ in rows if ok)
|
|
128
|
+
typer.echo(f"{n_ok}/{len(rows)} grammars load"
|
|
129
|
+
+ ("" if all_ok else " — missing grammars are skipped (their files "
|
|
130
|
+
"won't be analysed); see 'pip install stitchgraph[treesitter]'"))
|
|
131
|
+
raise typer.Exit(1 if strict and not all_ok else 0)
|
|
132
|
+
|
|
133
|
+
return app
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _make_command(typer, op: Operation):
|
|
137
|
+
"""Wrap an operation as a Typer command with the same caller-facing params."""
|
|
138
|
+
op_params = op.exposed_params()
|
|
139
|
+
|
|
140
|
+
def command(**kwargs: Any) -> None:
|
|
141
|
+
db = kwargs.pop("db")
|
|
142
|
+
as_json = kwargs.pop("json")
|
|
143
|
+
# Refuse (rather than silently create) a missing/never-indexed DB, and
|
|
144
|
+
# refuse cleanly on an unopenable --db (a directory, FIFO, device) instead
|
|
145
|
+
# of a raw sqlite traceback (panel R12; review 2026-07-03, F2b).
|
|
146
|
+
store, refusal = open_store(db, op.name)
|
|
147
|
+
if refusal is not None:
|
|
148
|
+
result = refusal
|
|
149
|
+
else:
|
|
150
|
+
assert store is not None
|
|
151
|
+
with store:
|
|
152
|
+
result = op.func(store, **kwargs)
|
|
153
|
+
if as_json:
|
|
154
|
+
typer.echo(_json.dumps(result.to_dict(), indent=2))
|
|
155
|
+
else:
|
|
156
|
+
typer.echo(render_text(op.name, result))
|
|
157
|
+
# An OPERATIONAL failure (missing/unopenable db) exits 2 so scripts can't mistake
|
|
158
|
+
# it for a clean result (review 2026-07-03, F10c); an op-level advisory refusal
|
|
159
|
+
# (e.g. get_matrix's too-broad-scope) keeps the envelope exit codes (RED=1, else 0).
|
|
160
|
+
raise typer.Exit(2 if refusal is not None else _exit_code(result))
|
|
161
|
+
|
|
162
|
+
# Give the wrapper a signature Typer can introspect: the op's params + --db/--json.
|
|
163
|
+
# Preserve each param's real type so Typer parses/validates it — rebuilding every
|
|
164
|
+
# param as `str` made `--limit 5` arrive as "5" (crashing int comparisons) and
|
|
165
|
+
# inverted bool flags.
|
|
166
|
+
params = []
|
|
167
|
+
for p in op_params:
|
|
168
|
+
anno = _anno_type(p.annotation)
|
|
169
|
+
if p.default is inspect.Parameter.empty:
|
|
170
|
+
params.append(inspect.Parameter(p.name, p.kind, annotation=anno))
|
|
171
|
+
else:
|
|
172
|
+
params.append(inspect.Parameter(
|
|
173
|
+
p.name, inspect.Parameter.KEYWORD_ONLY,
|
|
174
|
+
default=typer.Option(p.default, help=f"{p.name}"), annotation=anno))
|
|
175
|
+
params.append(inspect.Parameter(
|
|
176
|
+
"db", inspect.Parameter.KEYWORD_ONLY,
|
|
177
|
+
default=typer.Option("stitchgraph.db", help="index database path"), annotation=str))
|
|
178
|
+
params.append(inspect.Parameter(
|
|
179
|
+
"json", inspect.Parameter.KEYWORD_ONLY,
|
|
180
|
+
default=typer.Option(False, "--json", help="emit the raw envelope as JSON"),
|
|
181
|
+
annotation=bool))
|
|
182
|
+
command.__signature__ = inspect.Signature(params) # type: ignore[attr-defined]
|
|
183
|
+
command.__name__ = op.name
|
|
184
|
+
return command
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_ANNO_TYPES = {"str": str, "int": int, "float": float, "bool": bool}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _anno_type(annotation: Any) -> type:
|
|
191
|
+
"""Resolve an operation param's annotation to a concrete type for Typer.
|
|
192
|
+
|
|
193
|
+
Annotations are JSON-simple (operations.exposed_params filters to those) but
|
|
194
|
+
arrive as strings under `from __future__ import annotations`; default to `str`
|
|
195
|
+
for anything unrecognised (e.g. `int | None`)."""
|
|
196
|
+
if isinstance(annotation, type):
|
|
197
|
+
return annotation if annotation in _ANNO_TYPES.values() else str
|
|
198
|
+
if isinstance(annotation, str):
|
|
199
|
+
base = annotation.replace("Optional[", "").replace("]", "").split("|")[0].strip()
|
|
200
|
+
return _ANNO_TYPES.get(base, str)
|
|
201
|
+
return str
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _exit_code(result) -> int:
|
|
205
|
+
"""scan-style exit codes (design §13.3): non-zero when red issues exist. Operational
|
|
206
|
+
failures (missing/unopenable --db) exit 2 at the command level (review 2026-07-03,
|
|
207
|
+
F10c); an op-level advisory refusal deliberately stays 0 — it IS a clean answer."""
|
|
208
|
+
from ..core.envelope import Urgency
|
|
209
|
+
if result.urgency is Urgency.RED:
|
|
210
|
+
return 1
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def main() -> None:
|
|
215
|
+
build_app()()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""MCP adapter (design §3). Registers each core operation as an MCP tool by the
|
|
2
|
+
same name, deriving the schema from the operation's signature + docstring.
|
|
3
|
+
|
|
4
|
+
The MCP SDK is an optional dependency (`pip install stitchgraph[mcp]`); imported
|
|
5
|
+
lazily so `import stitchgraph` never requires it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..core.operations import Operation, registry
|
|
14
|
+
from ._guard import open_store
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _require_mcp():
|
|
18
|
+
try:
|
|
19
|
+
from mcp.server.fastmcp import FastMCP
|
|
20
|
+
except ModuleNotFoundError as exc: # pragma: no cover
|
|
21
|
+
raise SystemExit(
|
|
22
|
+
"The MCP server needs the MCP SDK. Install it with: "
|
|
23
|
+
"pip install 'stitchgraph[mcp]'"
|
|
24
|
+
) from exc
|
|
25
|
+
return FastMCP
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_server(db: str = "stitchgraph.db"):
|
|
29
|
+
FastMCP = _require_mcp()
|
|
30
|
+
server = FastMCP("stitchgraph")
|
|
31
|
+
for op in registry():
|
|
32
|
+
server.add_tool(_make_tool(op, db), name=op.name, description=op.summary)
|
|
33
|
+
return server
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_tool(op: Operation, db: str):
|
|
37
|
+
"""Wrap an operation as an MCP tool: open the store, call, return the envelope."""
|
|
38
|
+
|
|
39
|
+
def tool(**kwargs: Any) -> dict:
|
|
40
|
+
# Refuse (rather than silently create) a missing/never-indexed DB, and
|
|
41
|
+
# return an envelope instead of a raw sqlite traceback on an unopenable
|
|
42
|
+
# path (panel R12; review 2026-07-03, F2b).
|
|
43
|
+
store, refusal = open_store(db, op.name)
|
|
44
|
+
if refusal is not None:
|
|
45
|
+
return refusal.to_dict()
|
|
46
|
+
assert store is not None
|
|
47
|
+
with store:
|
|
48
|
+
result = op.func(store, **kwargs)
|
|
49
|
+
return result.to_dict()
|
|
50
|
+
|
|
51
|
+
# Expose only JSON-simple params (drop `store` and internal objects like
|
|
52
|
+
# `detector`, which pydantic can't schema-ify) — they fall back to defaults.
|
|
53
|
+
params = [
|
|
54
|
+
inspect.Parameter(p.name, inspect.Parameter.KEYWORD_ONLY,
|
|
55
|
+
default=(p.default if p.default is not inspect.Parameter.empty
|
|
56
|
+
else inspect.Parameter.empty),
|
|
57
|
+
annotation=(p.annotation if p.annotation is not inspect.Parameter.empty
|
|
58
|
+
else str))
|
|
59
|
+
for p in op.exposed_params()
|
|
60
|
+
]
|
|
61
|
+
tool.__signature__ = inspect.Signature(params) # type: ignore[attr-defined]
|
|
62
|
+
tool.__name__ = op.name
|
|
63
|
+
tool.__doc__ = op.summary
|
|
64
|
+
return tool
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main() -> None:
|
|
68
|
+
"""Entry point (`stitchgraph-mcp`). The DB path is configurable because MCP
|
|
69
|
+
clients launch servers with an arbitrary cwd — a hardcoded relative default
|
|
70
|
+
would resolve to the wrong directory and (pre-guard) answer from a fresh
|
|
71
|
+
empty index (review 2026-07-03, F2a). Precedence: --db > STITCHGRAPH_DB >
|
|
72
|
+
./stitchgraph.db."""
|
|
73
|
+
import argparse
|
|
74
|
+
import os
|
|
75
|
+
|
|
76
|
+
parser = argparse.ArgumentParser(
|
|
77
|
+
prog="stitchgraph-mcp",
|
|
78
|
+
description="stitchgraph MCP server — code-intelligence tools for LLM agents")
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--db",
|
|
81
|
+
default=os.environ.get("STITCHGRAPH_DB", "stitchgraph.db"),
|
|
82
|
+
help="index database path (env: STITCHGRAPH_DB; default: ./stitchgraph.db)")
|
|
83
|
+
args = parser.parse_args()
|
|
84
|
+
build_server(db=args.db).run()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Envelope -> human-readable text. Shared by the CLI and the report.
|
|
2
|
+
|
|
3
|
+
Stdlib-only so it can render without any optional dependency installed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from ..core.envelope import Result, Urgency
|
|
9
|
+
|
|
10
|
+
_URGENCY_TAG = {Urgency.RED: "[RED]", Urgency.ORANGE: "[ORANGE]", Urgency.GREEN: "[GREEN]"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_text(op_name: str, result: Result) -> str:
|
|
14
|
+
lines: list[str] = []
|
|
15
|
+
status = "ok" if result.ok else "no result"
|
|
16
|
+
head = f"{op_name}: {status} (confidence {result.confidence:.2f}, {result.provenance.value})"
|
|
17
|
+
if result.urgency:
|
|
18
|
+
head = f"{_URGENCY_TAG[result.urgency]} {head}"
|
|
19
|
+
lines.append(head)
|
|
20
|
+
|
|
21
|
+
if result.needs_review:
|
|
22
|
+
lines.append(" needs review:")
|
|
23
|
+
for reason in result.review_reasons:
|
|
24
|
+
lines.append(f" - {reason}")
|
|
25
|
+
|
|
26
|
+
lines.append(_render_payload(result.result))
|
|
27
|
+
|
|
28
|
+
if result.alternatives:
|
|
29
|
+
lines.append(f" alternatives: {len(result.alternatives)}")
|
|
30
|
+
if result.meta:
|
|
31
|
+
meta = ", ".join(f"{k}={v}" for k, v in result.meta.items())
|
|
32
|
+
lines.append(f" ({meta})")
|
|
33
|
+
return "\n".join(lines)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _render_payload(payload: object, indent: str = " ") -> str:
|
|
37
|
+
if payload is None:
|
|
38
|
+
return f"{indent}(none)"
|
|
39
|
+
if isinstance(payload, list):
|
|
40
|
+
if not payload:
|
|
41
|
+
return f"{indent}(empty)"
|
|
42
|
+
out = "\n".join(f"{indent}- {_one(item)}" for item in payload[:50])
|
|
43
|
+
if len(payload) > 50:
|
|
44
|
+
# Say the output was cut — silent truncation reads as "that's everything"
|
|
45
|
+
# (review 2026-07-03, F10e). --json always carries the full payload.
|
|
46
|
+
out += f"\n{indent}… {len(payload) - 50} more (use --json for the full list)"
|
|
47
|
+
return out
|
|
48
|
+
if isinstance(payload, dict):
|
|
49
|
+
return "\n".join(f"{indent}{k}: {_one(v)}" for k, v in payload.items())
|
|
50
|
+
return f"{indent}{payload}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _one(item: object) -> str:
|
|
54
|
+
if isinstance(item, dict):
|
|
55
|
+
return ", ".join(f"{k}={v}" for k, v in item.items())
|
|
56
|
+
return str(item)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Report adapter (design §8): point at an index, get a Markdown report.
|
|
2
|
+
|
|
3
|
+
The report is a fourth renderer over the same operations, organised into the
|
|
4
|
+
urgency tiers (Fix now / Look closer / Cleanup). Stdlib-only.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ..core import operations as ops
|
|
10
|
+
from ._guard import open_store
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_report(db: str = "stitchgraph.db", repo: str | None = None) -> str:
|
|
14
|
+
# A missing/never-indexed or unopenable db yields a one-line report, not a raw
|
|
15
|
+
# sqlite traceback or a confidently-empty report (panel R12; review 2026-07-03 F2b).
|
|
16
|
+
store, refusal = open_store(db, "report")
|
|
17
|
+
if refusal is not None:
|
|
18
|
+
return f"# stitchgraph report\n\n{'; '.join(refusal.review_reasons)}\n"
|
|
19
|
+
assert store is not None
|
|
20
|
+
with store:
|
|
21
|
+
orient = ops.orient(store)
|
|
22
|
+
scan = ops.scan(store)
|
|
23
|
+
stale = ops.find_stale(store)
|
|
24
|
+
# repo=None lets risk() default to the indexed root recorded in the DB, so
|
|
25
|
+
# `report --db <db>` includes the risk section from any cwd (issue #18).
|
|
26
|
+
risk = ops.risk(store, repo)
|
|
27
|
+
|
|
28
|
+
issues = scan.result or []
|
|
29
|
+
by_urgency = {u: [i for i in issues if i["urgency"] == u]
|
|
30
|
+
for u in ("red", "orange", "green")}
|
|
31
|
+
|
|
32
|
+
out: list[str] = ["# stitchgraph report", ""]
|
|
33
|
+
|
|
34
|
+
out += ["## Orientation", ""]
|
|
35
|
+
counts = (orient.result or {}).get("node_counts", {})
|
|
36
|
+
out.append(f"- Nodes indexed: {orient.meta.get('total_nodes', 0)}")
|
|
37
|
+
out.append(f"- By kind: {counts or '(none)'}")
|
|
38
|
+
hubs = (orient.result or {}).get("top_hubs", [])
|
|
39
|
+
out.append("- Read these first (hubs): "
|
|
40
|
+
f"{[h['id'].split('::')[-1] for h in hubs[:8]] or '(none)'}")
|
|
41
|
+
out.append("")
|
|
42
|
+
|
|
43
|
+
out += ["## \U0001f534 Fix now", ""]
|
|
44
|
+
_emit_issues(out, by_urgency["red"])
|
|
45
|
+
|
|
46
|
+
out += ["", "## \U0001f7e0 Look closer", ""]
|
|
47
|
+
_emit_issues(out, by_urgency["orange"])
|
|
48
|
+
|
|
49
|
+
out += ["", "## \U0001f7e2 Cleanup", ""]
|
|
50
|
+
stale_list = stale.result or []
|
|
51
|
+
out.append(f"- Stale code candidates: {len(stale_list)} "
|
|
52
|
+
f"(confidence {stale.confidence:.2f}, verify before removing)")
|
|
53
|
+
for c in stale_list[:20]:
|
|
54
|
+
out.append(f" - {c['id']}")
|
|
55
|
+
if len(stale_list) > 20:
|
|
56
|
+
out.append(f" - … {len(stale_list) - 20} more (run `stitchgraph find-stale --json`)")
|
|
57
|
+
_emit_issues(out, by_urgency["green"])
|
|
58
|
+
|
|
59
|
+
out += ["", "## Risk (git × structure)", ""]
|
|
60
|
+
if risk.ok:
|
|
61
|
+
hotspots = (risk.result or {}).get("hotspots", [])
|
|
62
|
+
hidden = (risk.result or {}).get("hidden_coupling", [])
|
|
63
|
+
for h in hotspots[:5]:
|
|
64
|
+
out.append(f"- {h['urgency']} {h['file']} "
|
|
65
|
+
f"(churn {h['churn']}, risk {h['risk']})")
|
|
66
|
+
if hidden:
|
|
67
|
+
out.append(f"- Hidden coupling pairs: {len(hidden)} "
|
|
68
|
+
"(co-change with no structural edge)")
|
|
69
|
+
# risk ran but found nothing notable (every churned file has zero centrality,
|
|
70
|
+
# no hidden coupling). Render an explicit marker so the section is never blank —
|
|
71
|
+
# mirrors how the Cleanup section reports an empty stale list (panels SS/TT).
|
|
72
|
+
if not hotspots and not hidden:
|
|
73
|
+
out.append("- (no risk hotspots or hidden coupling found)")
|
|
74
|
+
else:
|
|
75
|
+
out.append(f"- (skipped: {risk.review_reasons[0] if risk.review_reasons else 'no git'})")
|
|
76
|
+
|
|
77
|
+
return "\n".join(out)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _emit_issues(out: list[str], issues: list[dict]) -> None:
|
|
81
|
+
if not issues:
|
|
82
|
+
out.append("- (none)")
|
|
83
|
+
return
|
|
84
|
+
for i in issues[:25]:
|
|
85
|
+
node = i.get("node", "").split("::")[-1]
|
|
86
|
+
out.append(f"- **{i['kind']}** `{node}` — {i.get('reason', '')}")
|
|
87
|
+
if len(issues) > 25:
|
|
88
|
+
out.append(f"- … {len(issues) - 25} more (run `stitchgraph scan --json`)")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main() -> None:
|
|
92
|
+
import sys
|
|
93
|
+
db = sys.argv[1] if len(sys.argv) > 1 else "stitchgraph.db"
|
|
94
|
+
repo = sys.argv[2] if len(sys.argv) > 2 else None
|
|
95
|
+
print(build_report(db, repo))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""stitchgraph core — pure, stdlib-only. Never imports CLI/MCP libraries."""
|
|
2
|
+
|
|
3
|
+
from .envelope import Provenance, Result, Urgency, ok, refuse
|
|
4
|
+
from .model import Edge, Node, NodeKind, Relation
|
|
5
|
+
from .operations import Operation, registry
|
|
6
|
+
from .store import Store
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Provenance", "Result", "Urgency", "ok", "refuse",
|
|
10
|
+
"Edge", "Node", "NodeKind", "Relation",
|
|
11
|
+
"Operation", "registry", "Store",
|
|
12
|
+
]
|
stitchgraph/core/_scc.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Tarjan strongly-connected-components — shared core.
|
|
2
|
+
|
|
3
|
+
Extracted in v2.3.0: the SCC algorithm was duplicated verbatim in `reach.py` (call / import
|
|
4
|
+
cycles) and `dataloop.py` (data-feedback loops). The two call sites differ only in how they
|
|
5
|
+
build the adjacency and what they do with the result — the *core* (a recursive Tarjan under a
|
|
6
|
+
temporarily-raised recursion limit) is identical — so it lives here once.
|
|
7
|
+
|
|
8
|
+
`tarjan_scc` is behaviour-preserving: same component output, same iteration order (driven by the
|
|
9
|
+
caller-supplied `seeds`), same recursion-limit handling (raised for deep graphs, restored in a
|
|
10
|
+
`finally` so a raised limit never leaks to the host — panel QQQ LOW).
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def tarjan_scc(
|
|
19
|
+
adj: Mapping[str, Sequence[str]], seeds: Iterable[str], node_count: int,
|
|
20
|
+
) -> list[list[str]]:
|
|
21
|
+
"""Tarjan's SCC over `adj`, visiting `seeds` in order. `node_count` sizes the temporary
|
|
22
|
+
recursion-limit raise for deep graphs. Returns components in reverse-topological order."""
|
|
23
|
+
index: dict[str, int] = {}
|
|
24
|
+
low: dict[str, int] = {}
|
|
25
|
+
on_stack: set[str] = set()
|
|
26
|
+
stack: list[str] = []
|
|
27
|
+
counter = [0]
|
|
28
|
+
out: list[list[str]] = []
|
|
29
|
+
|
|
30
|
+
_old_limit = sys.getrecursionlimit()
|
|
31
|
+
sys.setrecursionlimit(max(10000, node_count * 4 + 1000))
|
|
32
|
+
|
|
33
|
+
def strongconnect(v: str) -> None:
|
|
34
|
+
index[v] = low[v] = counter[0]
|
|
35
|
+
counter[0] += 1
|
|
36
|
+
stack.append(v)
|
|
37
|
+
on_stack.add(v)
|
|
38
|
+
for w in adj.get(v, ()):
|
|
39
|
+
if w not in index:
|
|
40
|
+
strongconnect(w)
|
|
41
|
+
low[v] = min(low[v], low[w])
|
|
42
|
+
elif w in on_stack:
|
|
43
|
+
low[v] = min(low[v], index[w])
|
|
44
|
+
if low[v] == index[v]:
|
|
45
|
+
comp: list[str] = []
|
|
46
|
+
while True:
|
|
47
|
+
w = stack.pop()
|
|
48
|
+
on_stack.discard(w)
|
|
49
|
+
comp.append(w)
|
|
50
|
+
if w == v:
|
|
51
|
+
break
|
|
52
|
+
out.append(comp)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
for v in seeds:
|
|
56
|
+
if v not in index:
|
|
57
|
+
strongconnect(v)
|
|
58
|
+
finally:
|
|
59
|
+
sys.setrecursionlimit(_old_limit)
|
|
60
|
+
return out
|