{"slug": "introducing-celmis-self-hosted-code-intelligence-over-a-symbol-graph", "title": "Introducing Celmis: self-hosted code intelligence over a symbol graph", "summary": "Celmis, a self-hosted code intelligence tool, builds a deterministic symbol graph of repositories using tree-sitter, enabling cross-repository queries and code review without model involvement. The tool indexes code once and offers 18 tools, including search, review, and dependency auditing, with zero false positives in a demo PR review.", "body_md": "**Celmis is self-hosted code intelligence.** It reads a set of repositories once and\n\nkeeps a symbol graph of them; asking questions, reviewing pull requests, auditing\n\ndependencies, routing alerts and serving an MCP endpoint are then all different ways of\n\nreading that one index, rather than five products each holding their own copy of your\n\ncode. It runs on one machine under `docker compose`\n\n, with whichever model provider you\n\nalready pay for behind it.\n\nThis is the introduction — what it does, what it refuses to do, and how to run it. The\n\nquickest way to say why it exists is to show the thing a diff-only tool structurally\n\ncannot do.\n\nI asked a question that spanned two repositories, and the answer quoted both.\n\nThe question was ordinary: *\"How does the gateway talk to the payments service? Name the\nfunction on each side.\"*\n\nThe answer found a Python publisher:\n\n``` php\ndef publish(self, batch_id: str, entries: dict[str, int]) -> None:\n    \"\"\"Emit one settlement event on the published topic.\"\"\"\n    self.producer.send(\n        SETTLEMENT_TOPIC,\n        json.dumps({\"batch_id\": batch_id, \"entries\": entries}).encode(),\n    )\n```\n\nand a TypeScript listener in a different repository:\n\n``` js\nstart(): void {\n  this.bus.on(SETTLEMENT_TOPIC, (event) => {\n    const payload = JSON.stringify({ type: \"settlement\", ...event });\n    for (const s of this.sockets) s.send(payload);\n  });\n}\n```\n\nThey never call each other. They meet on a Kafka topic. Then the answer added something\n\nnobody asked for:\n\nDuplicated contract.The topic name (`payments.settlement.v2`\n\n) and the event payload\n\nstructure are hardcoded in two separate repositories —`src/config.py`\n\nin payments and\n\n`src/contract.ts`\n\nin the gateway. Changing the topic name or the payload schema in one\n\nrepository without updating the other will silently break the integration.\n\n**A reviewer that reads only the diff cannot say that.** It never had the other repository\n\nopen. That is not a model-quality problem — no amount of reasoning recovers a file that\n\nwas never in the context.\n\nRead the repositories **once**. Build a symbol graph — deterministically, with tree-sitter,\n\nno model involved. Then everything else is a different way of reading that one index\n\nrather than a separate product with its own copy of your code:\n\n`file:line`\n\ncitations, across repository and language\nboundaries.The index is the product. The rest are surfaces.\n\nOn a real pull request in a demo repository, three findings, all on real lines:\n\n``` js\n6:  const first = vals[0];              // vals is {} — undefined, not a TypeError\n7:  for (let i = 0; i <= vals.length; i++)   // off by one\n16: const n = parseInt(raw);            // never throws, so the catch below is dead\n```\n\nThree inline comments with `suggestion`\n\nblocks, one summary comment, **zero false\npositives on that run**. I verified all three against the file in the PR branch by hand,\n\nEighteen tools over the same index. Here is a real session, trimmed:\n\n``` php\n--> initialize\n<-- 200   serverInfo: { \"name\": \"celmis\", \"version\": \"1.29.1\" }\n\n--> tools/list\n<-- 200   18 tools\n          list_projects        get_api_surface       bootstrap_client\n          search_symbols       list_accessible_repos start_integration_walk\n          find_consumers       get_review            route_incident\n          get_owner            get_review_policy     get_dep_audit\n          get_architecture     list_deprecations     list_dep_findings\n          …\n\n--> tools/call  search_symbols\n          { \"project_id\": \"083bd97a-…\", \"query\": \"SETTLEMENT_TOPIC\" }\n<-- 200\n          {\n            \"matches\": [\n              { \"repo_slug\": \"…celmis-demo-gateway\",  \"kind\": \"variable\",\n                \"file\": \"src/contract.ts\", \"line\": 2 },\n              { \"repo_slug\": \"…celmis-demo-payments\", \"kind\": \"constant\",\n                \"file\": \"src/config.py\",   \"line\": 9 }\n            ],\n            \"count\": 2\n          }\n```\n\nOne query. Two repositories, two languages, the same contract symbol — from a client that\n\nhas never checked either of them out. `find_consumers`\n\nis the one I use most: it answers\n\n\"what breaks if I change this\" across the whole set.\n\nThis is the surface I nearly left out of this article, which was a mistake, because it is\n\nthe one that closes the loop.\n\nYour services are already producing alerts. They land in a channel where somebody has to\n\nwork out which repository the failing service actually is, who owns it, and whether the\n\nthing that broke was touched recently. That lookup is the expensive part — not the alert.\n\nSo the same index answers it. An ingest endpoint takes the alert, a binding routes it, and\n\nthe card arrives in chat:\n\n```\nPOST /webhook/alerts/{token}\n  { \"severity\": \"critical\",\n    \"repo_hint\": \"celmis-codereviewer/celmis-demo-gateway\",\n    \"title\": \"checkout: unhandled exception in settle()\" }\n\n→ notif_delivered event=alert_received\n  repo=celmis-codereviewer/celmis-demo-gateway severity=critical\n```\n\nReview results ride the same rails — a finished pull-request review posts its own card:\n\n`Review CHANGES · PR #4 — 0 critical · 3 error · 0 warn · 0 info`\n\n.\n\nTwo things worth stealing from how this is wired, both of which I got wrong first:\n\n**The webhook signature is checked before anything else.** Wrong signature → `401`\n\n.\n\nCorrect → `202`\n\n. Replay the exact same delivery → `{\"status\": \"duplicate\"}`\n\nrather than a\n\nsecond review and a second bill. Delivery IDs are cheap; duplicated model calls are not.\n\n**A failed channel test must not echo the URL it tested.** Google Chat webhook URLs carry\n\n`key`\n\nand `token`\n\nin the query string — **the URL is a credential**. `httpx`\n\nputs the\n\nrequest URL in the exception text, and an early version of the endpoint returned\n\n`str(exc)`\n\nverbatim, which meant a failed test handed the caller back the secret it was\n\ntesting. If you are building anything that tests a user-supplied webhook, go and check\n\nthat path in your own code right now.\n\nThe dependency audit is the one part that is **deterministic end to end** — native\n\nauditors and OSV.dev, no LLM in the loop. It produces two things.\n\nA CycloneDX SBOM, and an evidence pack:\n\n```\nsbom/<repo>.cdx.json   CycloneDX, one per repository\nfindings.json          what was found against those components\ntimeline.jsonl         when each fact entered the record\nMANIFEST.json          sha256 of every file above\n```\n\nThat last file is the point. An archive of files is not evidence — nothing in it stops\n\nthe contents from having been edited afterwards. A manifest of digests means **a third\nparty can verify the pack without trusting the machine that produced it**:\n\n```\n{\n  \"algorithm\": \"sha256\",\n  \"files\": {\n    \"findings.json\": \"921412d4bf97eb32fa4b3e8ad09447dab762f19d31ffec45bddb6d7962bf08e5\",\n    \"sbom/gateway.cdx.json\": \"6eadeafad0043b85a51b65ddddba84ef7b43081064ed9e1b320c62838ddc3d8e\",\n    \"timeline.jsonl\": \"b1815a8e3607712aa9332cd1f8d2d0ec6f93cd8dce53dd92844b8edcde790b3d\"\n  },\n  \"generated_at\": \"2026-08-26T19:09:41Z\"\n}\n```\n\n**Why now:** from **11 September 2026**, under the EU Cyber Resilience Act, manufacturers\n\nmust report an actively exploited vulnerability to ENISA and their national CSIRT within\n\n**24 hours**. The SBOM itself is not mandated until December 2027 — which is the trap,\n\nbecause on a 24-hour clock the first question is not how to word the notification. It is\n\nwhether you ship the component at all, in which service, at which version.\n\nThe document is due in 2027. The visibility it describes is needed fifteen months earlier.\n\nThe audit reports **what it could not check**, as prominently as what it found:\n\n```\nNot fully checked (4). Treat a zero here as unknown, not as safe.\n\n- …demo-gateway — npm via npm-audit: no lock file\n  (package-lock.json / pnpm-lock.yaml / yarn.lock) — cannot resolve the tree\n- …demo-gateway — all via osv-scanner: recognised no manifest or lock file here\n- …e2e-probe/requirements.txt — PyPI via pip-audit: dependency resolution failed\n  — audited 4 pinned requirements directly, without the transitive tree\n```\n\n**An unchecked ecosystem reports zero vulnerabilities exactly like a clean one.** If your\n\ntooling cannot tell you which of the two you are looking at, that gap *is* the finding.\n\nSteal this behaviour regardless of what you use.\n\nOne machine. Postgres and Qdrant bundled — no external cluster to provision.\n\n```\ngit clone https://github.com/Celmis-labs/Celmis.git celmis\ncd celmis\n./scripts/init-env.sh          # generates .env, every secret in the format it needs\ndocker compose --env-file .env up -d\ndocker compose ps\n```\n\nOn a clean server that took **197 seconds** from `git clone`\n\nto six healthy services —\n\nmeasured, not estimated. About 1.1 GB of RAM at peak during indexing, 565 MB at rest.\n\nBring your own model key: Gemini, Anthropic, OpenAI, OpenRouter, Groq or Mistral. A free\n\nGemini key is enough to evaluate it. **No telemetry, no licence check** — the only\n\noutbound calls are the ones you configure. AGPLv3, the whole thing, not open core.\n\n**17th of 50** on the Martian Code Review Bench offline set, stable under all three\n\njudges.\n\nIt measures one of the surfaces above — pull-request review on isolated\n\nsingle-repository PRs. That set has no sibling service for a symbol to have consumers in,\n\nso the cross-repository work this is built around contributes nothing to the score. It is\n\non the front page of the site with that explanation next to it rather than instead of it.\n\nI also audited every one of the 79 findings the benchmark counted against us. **33 were\nreal defects the reference set was silent about.** That audit is published in full, with\n\n`pip install`\n\nand\n`npm ci`\n\nneed it.Source, docs and the benchmark audit: [https://celmis-labs.github.io](https://celmis-labs.github.io)\n\nHappy to answer anything — especially about the benchmark methodology, which is the part\n\nthat deserves an argument.", "url": "https://wpnews.pro/news/introducing-celmis-self-hosted-code-intelligence-over-a-symbol-graph", "canonical_source": "https://dev.to/constantinemakoid/introducing-celmis-self-hosted-code-intelligence-over-a-symbol-graph-23eb", "published_at": "2026-08-27 16:37:48+00:00", "updated_at": "2026-08-27 16:48:55.006263+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools"], "entities": ["Celmis", "tree-sitter", "Kafka", "MCP"], "alternates": {"html": "https://wpnews.pro/news/introducing-celmis-self-hosted-code-intelligence-over-a-symbol-graph", "markdown": "https://wpnews.pro/news/introducing-celmis-self-hosted-code-intelligence-over-a-symbol-graph.md", "text": "https://wpnews.pro/news/introducing-celmis-self-hosted-code-intelligence-over-a-symbol-graph.txt", "jsonld": "https://wpnews.pro/news/introducing-celmis-self-hosted-code-intelligence-over-a-symbol-graph.jsonld"}}