Inspiration

Every engineer knows the moment: you're about to change a function and you pause, because you're not sure what else calls it. grep gives you text matches, not structure. IDEs give you "find usages" for one hop, if you're lucky. Nobody actually walks the call graph and tells you, in plain English, what's at stake.

We wanted to build something that treats the call graph as a first-class thing you can traverse and reason over — not a side artifact of static analysis, but the actual data model the program operates on. Jac, where the graph is the data structure and traversal is a language primitive, was the obvious fit.

How we built it

  • graph.jac — the schema: File/Function nodes, Calls/Imports edges.
  • parser.py — a stdlib-ast-based Python parser, zero dependencies.
  • ingest.jac — def:pub ingest_repo(repo_path), deliberately a function and not a walker, since it builds the graph rather than walking it.
  • blastradius.jac — walker:pub BlastRadius(target_fn, max_hops), the real traversal: spawned onto a Function node, it follows Calls edges outward hop by hop, guarded against cycles and bounded by max_hops, collecting every affected function into a reports list as it moves.
  • agent.jac — def:pub explain_risk(...) by llm(tools=[...]), a typed LLM call with real tool-calling (read_source, suggest_tests), grounded in the walker's output via sem prompt annotations that explicitly tell the model to trust the traversal instead of re-guessing it.
  • main.sv.jac — the entry point that wires every public endpoint into one running service.

Everything — REST endpoints, Swagger docs, a live graph view, persistence — comes out of a single jac start. There's no separate backend to stand up.

We verified it end-to-end against its own source: ingesting the BlastRadius repo itself and asking for the blast radius of _module_path correctly returns _module_path and its one real caller, parse_repo.

Challenges we ran into

The hardest bugs weren't in our four .jac files — they were in getting a framework actually call a real model in production, and each one only surfaced because werefused to trust a 200 OK as proof anything actually worked:

  • A silently-mocked LLM call. explain_risk worked fine in jac test against a MockLLM, but once wired to jac start with a real API key, it kept returning the exact same canned sentence no matter the input. We proved it wasn't hitting any model at all by feediid API key and nonsense function names — same output, same success code, every time.The real root cause took real digging to find: .test.jac files are annexes that permanently compile into their base module, not test-scoped files — so our test file's module-level glob llm = MockLLM(...) was overriding the ambient model in every run mode, jac start incltest we wrote. The fix was moving the mock into a runtime reassignment inside thetest body itself, so it only shadows the global for that one test's execution instead of merging into the compiled module permanently.
  • A platform assumption we didn't know about. Deploying to JacHammer, the previen't read main.jac— even though ourjac.tomlexplicitly setsentry-point ="main.sv.jac". JacHammer's preview supervisor hardcodesmain.jac` regardless of that config, so we added a matching entry file.
  • A silent download failure. Trying to avoid needing our own OpenAI billing, we via jac model pull — which reported success (exit code 0) twice while actuallyleaving a truncated, unusable file on disk both times, stalled at the identical byte offset. Traced to unauthenticated Hugging Face Hub rate limiting that the tool doesn't surface as a failure.

What we learned

Jac's graph-as-data-model is genuinely different from bolting a graph library onto Python — walkers as first-class traversal primitives make "compute the blast radius" a natural fit instead of aworkaround dressed up to look like one. We also relearned a harder lesson: with any tatus code is not evidence of success. Every real bug we hit this weekend wasinvisible until we deliberately forced a failure path — a bad API key, a nonsense input, a byte-for-byte disk check — and watched the tool still claim it worked.
That same instinct carried into hardening the API itself once it was actually working: ingest_repo on a nonexistent path used to return a fake 200 OK with all-zero counts instead of a clear error, and BlastRadius on an unknown function name returned an ambiguous empty lis clearly instead of silently. We also found that our two LLM tool functions(read_source, suggest_tests) had zero direct test coverage — they were only ever exercised if the model happened to call them — so we added tests that call them directly, not just through the model's own judgment.

Built With

Share this project:

Updates

Submission history