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.
Files changed (57) hide show
  1. stitchgraph/__init__.py +85 -0
  2. stitchgraph/adapters/__init__.py +4 -0
  3. stitchgraph/adapters/_guard.py +47 -0
  4. stitchgraph/adapters/cli.py +219 -0
  5. stitchgraph/adapters/mcp.py +88 -0
  6. stitchgraph/adapters/render.py +56 -0
  7. stitchgraph/adapters/report.py +99 -0
  8. stitchgraph/core/__init__.py +12 -0
  9. stitchgraph/core/_scc.py +60 -0
  10. stitchgraph/core/algebra.py +167 -0
  11. stitchgraph/core/config.py +104 -0
  12. stitchgraph/core/coverage_query.py +194 -0
  13. stitchgraph/core/coverage_scaffold.py +258 -0
  14. stitchgraph/core/dataloop.py +49 -0
  15. stitchgraph/core/entrypoints.py +137 -0
  16. stitchgraph/core/envelope.py +156 -0
  17. stitchgraph/core/extract/__init__.py +60 -0
  18. stitchgraph/core/extract/_testfile.py +22 -0
  19. stitchgraph/core/extract/python.py +1808 -0
  20. stitchgraph/core/extract/treesitter.py +3072 -0
  21. stitchgraph/core/gitrisk.py +98 -0
  22. stitchgraph/core/graphdiff.py +127 -0
  23. stitchgraph/core/model.py +140 -0
  24. stitchgraph/core/modes.py +411 -0
  25. stitchgraph/core/operations.py +2107 -0
  26. stitchgraph/core/reach.py +293 -0
  27. stitchgraph/core/resolve/__init__.py +140 -0
  28. stitchgraph/core/resolve/events.py +124 -0
  29. stitchgraph/core/resolve/express.py +144 -0
  30. stitchgraph/core/resolve/html.py +113 -0
  31. stitchgraph/core/resolve/jedi_resolver.py +96 -0
  32. stitchgraph/core/resolve/jsfetch.py +145 -0
  33. stitchgraph/core/resolve/orm.py +111 -0
  34. stitchgraph/core/resolve/routes.py +118 -0
  35. stitchgraph/core/resolve/spring.py +117 -0
  36. stitchgraph/core/resolve/sql.py +139 -0
  37. stitchgraph/core/runtime.py +192 -0
  38. stitchgraph/core/similar.py +301 -0
  39. stitchgraph/core/spectral.py +252 -0
  40. stitchgraph/core/store.py +882 -0
  41. stitchgraph/core/structure.py +570 -0
  42. stitchgraph/core/structure_bash.py +584 -0
  43. stitchgraph/core/structure_common.py +120 -0
  44. stitchgraph/core/structure_cpp.py +928 -0
  45. stitchgraph/core/structure_csharp.py +908 -0
  46. stitchgraph/core/structure_go.py +665 -0
  47. stitchgraph/core/structure_java.py +752 -0
  48. stitchgraph/core/structure_js.py +705 -0
  49. stitchgraph/core/structure_php.py +752 -0
  50. stitchgraph/core/structure_ruby.py +649 -0
  51. stitchgraph/core/structure_rust.py +798 -0
  52. stitchgraph/core/watch.py +43 -0
  53. stitchgraph-3.27.1.dist-info/METADATA +316 -0
  54. stitchgraph-3.27.1.dist-info/RECORD +57 -0
  55. stitchgraph-3.27.1.dist-info/WHEEL +4 -0
  56. stitchgraph-3.27.1.dist-info/entry_points.txt +3 -0
  57. stitchgraph-3.27.1.dist-info/licenses/LICENSE +21 -0
@@ -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,4 @@
1
+ """Thin surfaces over core.operations. Each renders the same Result envelope:
2
+ CLI -> terminal, MCP -> tool result, report -> Markdown. Adapters import core;
3
+ core never imports adapters (design §3).
4
+ """
@@ -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
+ ]
@@ -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