graphify is a Claude Code skill backed by a Python library. The skill orchestrates the library; the library can be used standalone.
detect() → extract() → build_graph() → cluster() → analyze() → report() → export()
Each stage is a single function in its own module. They communicate through plain Python dicts and NetworkX graphs - no shared state, no side effects outside graphify-out/.
| Module | Function | Input → Output |
|---|---|---|
detect.py |
collect_files(root) |
directory → [Path] filtered list |
extract.py |
extract(path) |
file path → {nodes, edges} dict |
build.py |
build_graph(extractions) |
list of extraction dicts → nx.Graph |
cluster.py |
cluster(G) |
graph → graph with community attr on each node |
analyze.py |
analyze(G) |
graph → analysis dict (god nodes, surprises, questions) |
report.py |
render_report(G, analysis) |
graph + analysis → GRAPH_REPORT.md string |
export.py |
export(G, out_dir, ...) |
graph → Obsidian vault, graph.json, graph.html, graph.svg |
callflow_html.py |
write_callflow_html(...) |
graphify-out files → Mermaid architecture/call-flow HTML |
ingest.py |
ingest(url, ...) |
URL → file saved to corpus dir |
cache.py |
check_semantic_cache / save_semantic_cache |
files → (cached, uncached) split |
security.py |
validation helpers | URL / path / label → validated or raises |
validate.py |
validate_extraction(data) |
extraction dict → raises on schema errors |
serve.py |
start_server(graph_path) |
graph file path → MCP stdio server |
watch.py |
watch(root, flag_path) |
directory → writes flag file on change |
benchmark.py |
run_benchmark(graph_path) |
graph file → corpus vs subgraph token comparison |
Every extractor returns:
{
"nodes": [
{"id": "unique_string", "label": "human name", "source_file": "path", "source_location": "L42"}
],
"edges": [
{"source": "id_a", "target": "id_b", "relation": "calls|imports|uses|...", "confidence": "EXTRACTED|INFERRED|AMBIGUOUS"}
]
}validate.py enforces this schema before build_graph() consumes it.
| Label | Meaning |
|---|---|
EXTRACTED |
Relationship is explicitly stated in the source (e.g., an import statement, a direct call) |
INFERRED |
Relationship is a reasonable deduction (e.g., call-graph second pass, co-occurrence in context) |
AMBIGUOUS |
Relationship is uncertain; flagged for human review in GRAPH_REPORT.md |
- Add a
extract_<lang>(path: Path) -> dictfunction inextract.pyfollowing the existing pattern (tree-sitter parse → walk nodes → collectnodesandedges→ call-graph second pass for INFERREDcallsedges). - Register the file suffix in
extract()dispatch andcollect_files(). - Add the suffix to
CODE_EXTENSIONSindetect.pyand_WATCHED_EXTENSIONSinwatch.py. - Add the tree-sitter package to
pyproject.tomldependencies. - Add a fixture file to
tests/fixtures/and tests totests/test_languages.py.
Parses .abap files using tree-sitter-abap (local fork at ../8 - tree-sitter-abap).
Nodes extracted:
| Label pattern | ID scheme | Source construct |
|---|---|---|
CLASS <name> DEFINITION |
abap_cls_* |
class_definition AST node |
CLASS <name> |
abap_cls_* |
class_implementation AST node (same ID as DEFINITION) |
<class>-><method> |
<stem>_<cls>_<meth> |
method_implementation inside class impl |
INTERFACE <name> |
abap_intf_* |
interface_definition AST node |
FUNCTION GROUP <name> |
abap_fg_* |
function_pool_statement in TOP include |
FUNCTION <name> |
abap_fn_* |
function_definition AST node |
REPORT <name> |
abap_prog_* |
report_statement AST node |
FORM <name> |
<stem>_<name> |
form_definition AST node (file-scoped) |
EVENT <name> |
abap_event_* |
events_declaration / class_events_declaration |
TRANSACTION <tcode> |
abap_tran_* |
extract_tran() from .tran.xml |
BADI-><method> |
abap_badi_method_* |
call_badi_statement (runtime stub) |
Edges extracted:
| Relation | Confidence | Trigger |
|---|---|---|
contains |
EXTRACTED |
file→class, class→method/event, file→FM/FG/interface/form |
calls |
EXTRACTED |
CALL FUNCTION "...", call_badi_statement |
calls |
INFERRED |
method -> / => call, PERFORM, CALL METHOD |
submits |
EXTRACTED |
SUBMIT <prog> (static, Z/Y only; dynamic skipped) |
uses |
INFERRED |
TYPE REF TO, NEW, CREATE OBJECT, GET BADI TYPE, SET HANDLER me->, static ZCL=>METHOD() → class node, RAISE EXCEPTION TYPE zcx_xxx → exception class node |
raises |
EXTRACTED |
RAISE EVENT <name> |
launches |
EXTRACTED |
.tran.xml TCODE → program (Z/Y only) |
Node attributes (ABAP-specific):
kind:"z_custom"for objects starting withZorY;"sap_standard"for all others. Enables--hide-deadand graph filters to separate customer code from SAP dependencies. Events inside Z/Y classes inherit the class kind (event names don't follow the Z/Y convention).dead_candidate: set bybuild.mark_dead_candidates(G)— see below.dev_tool: set onREPORTnodes with no cross-file callers — utility programs run directly from SE38, not called from other code.tcode,pgmna,dypno,ttext: set on transaction nodes from.tran.xmlexports.
ID strategy (global IDs enable cross-file reconciliation):
| Scheme | Objects |
|---|---|
abap_cls_* |
Classes — same ID in definition file and in TYPE REF TO stubs |
abap_intf_* |
Interfaces — same ID in definition and reference |
abap_fn_* |
Function modules — same ID in FM definition and CALL FUNCTION stub |
abap_fg_* |
Function groups |
abap_prog_* |
Programs/reports — same ID in .abap source and .tran.xml stub |
abap_event_* |
Events — same ID in declaration and RAISE EVENT stub |
abap_tran_* |
Transactions (from .tran.xml) |
abap_badi_method_* |
BADI method stubs (runtime-resolved, not reconciled) |
abap_method_* |
Methods — global, _make_id("abap_method", class, method) |
<stem>_<name> |
FORMs — file-scoped |
Stub nodes: cross-file references create lightweight placeholder nodes via _ensure_stub() with source_file="" and source_location=None. When the definition file is processed, G.add_node() overwrites the stub with real values. source_location=None signals mark_dead_candidates to skip stubs.
Instance call resolution (pre-pass): before the main AST traversal, a pre-pass builds var_type: dict[str, str] by scanning all data_spec nodes in the file for typing → reference_type → ref_to → identifier. Only Z/Y-prefixed class names are recorded. This map is used to resolve lo_var->METHOD() calls: if lo_var is in the map its class is used; otherwise the call is silently skipped. Variables declared as TYPE REF TO ZIF_* (interface references) are not tracked and their calls go unresolved — see Known Limitations below.
Static call → class edge: ZCL_FOO=>METHOD() emits two edges: a calls INFERRED to the method stub and a uses INFERRED to the abap_cls_* node. The second edge is needed so mark_dead_candidates() sees non-zero cross-file in-degree on the class node even when no other reference type reaches it.
ID global vs file-scoped — method IDs: all method nodes use a global scheme (abap_method_*) independent of which file is being processed. This ensures that a stub created by a caller in zcl_bar.abap (via SET HANDLER me->m or a static ZCL_FOO=>m call) shares the same ID as the definition in zcl_foo.abap, enabling cross-file in-degree counting in mark_dead_candidates. FORMs remain file-scoped (<stem>_<name>) because FORM names are not globally unique.
Known limitations of static analysis:
| Limitation | Impact | Workaround / future fix |
|---|---|---|
Interface-typed variables (TYPE REF TO ZIF_*) not resolved |
lo_svc->method() where lo_svc is typed to an interface produces no edge; implementing class methods may appear as dead_candidate |
Would require mapping ZIF→implementing ZCL, out of scope for static analysis |
Chained calls (ZCL_A=>get( )->method( )) silently skipped |
No edge emitted for the right-hand call | Requires evaluating return types; out of scope |
| Pre-pass has no scope awareness | If a variable is declared twice with different types (shadowing), the last declaration wins for the whole file | Very rare in ABAP; acceptable approximation |
| Callers outside the corpus (Smartforms, RFC, CMOD exits) | Z objects called only from SAP-standard auto-generated code appear with zero in-degree → dead_candidate: true |
List known-alive objects in <corpus_root>/.graphify_known_alive (one name per line, case-insensitive, # comments ignored) |
Parses abapGit .tran.xml exports. These files come in two formats: (1) bare sibling elements <TSTC>…</TSTC><TSTCT>…</TSTCT> (older abapGit) and (2) a proper XML document with <?xml?> declaration and <asx:abap>/<asx:values> root (current abapGit). The extractor handles both: it strips any <?xml?> declaration with a non-greedy regex, wraps the remaining content in a synthetic <root>, and locates TSTC/TSTCT via namespace-agnostic XPath (.//{*}TSTC) so the asx: prefix is transparent. A 512 KB size guard prevents oversized file DoS.
Emits one abap_tran node per file plus a launches EXTRACTED edge to an abap_prog stub when PGMNA starts with Z or Y. The stub ID matches extract_abap's REPORT node ID, so the two nodes merge automatically when the program source is in the corpus.
build.mark_dead_candidates(G) runs after build_graph() and sets dead_candidate: true on Z/Y custom objects with zero cross-file in-degree (no callers from other files in the corpus).
Marked: CLASS Z*/Y* DEFINITION and Z*/Y*-><method> / Z*/Y*=><method> nodes from .abap files; .prog.abap include nodes whose label matches the file stem.
Never marked (and why):
| Category | Reason |
|---|---|
Function modules (FUNCTION *) |
May be called from SAP-standard enhancement frameworks (CMOD/SMOD exits, classic BADIs) outside the corpus |
Function groups (FUNCTION GROUP *) |
Containers; zero direct callers is expected |
FORMs (FORM *) |
Called via PERFORM from programs not always in corpus |
Reports (REPORT *) |
Receive dev_tool: true if no callers — utility programs run from SE38 |
Local classes (LCL_*, MCL_*) |
File-local, never cross-file |
| Test classes / test methods | Identified by FOR TESTING / *_TEST patterns |
| SAP-standard objects (no Z/Y prefix) | Not customer code |
Stub nodes (source_location=None) |
No definition in corpus |
All external input passes through graphify/security.py before use:
- URLs →
validate_url()(http/https only) +_NoFileRedirectHandler(blocks file:// redirects) - Fetched content →
safe_fetch()/safe_fetch_text()(size cap, timeout) - Graph file paths →
validate_graph_path()(must resolve insidegraphify-out/) - Node labels →
sanitize_label()(strips control chars, caps 256 chars, HTML-escapes)
See SECURITY.md for the full threat model.
One test file per module under tests/. Run with:
pytest tests/ -qAll tests are pure unit tests - no network calls, no file system side effects outside tmp_path.