-
Notifications
You must be signed in to change notification settings - Fork 0
scip code graph spike
Date: 2026-06-02 · Status: spike complete, model validated on Go · Author: Claude (Opus) + Ian
Before designing the code-graph ingester (SCIP index → Apache AGE property graph), validate the actual SCIP data model against ground truth rather than assumptions — the same "prove it before building on it" discipline used for the AGE substrate (BILL-52). This spike ran scip-go over a tiny synthetic Go module whose every symbol and relationship is known in advance, then dissected the emitted index.
Bottom line: the full code graph (vertices + CONTAINS/DEFINES/CALLS/IMPLEMENTS/REFERENCES) is cleanly reconstructible from a SCIP index. Two non-obvious mechanics — interface-dispatch indirection and enclosing_range for caller attribution — are documented below and must be designed for.
The SCIP toolchain migrated GitHub orgs: sourcegraph/* → scip-code/* (the scip-code org was created 2026-01-09; repos transferred with full history — gh api repos/sourcegraph/scip-go now resolves to scip-code/scip-go, 2022 creation date and 68★ carried over, dedicated domain scip-code.org). This is a legitimate spin-out, not a typosquat — confirmed by the fact that Sourcegraph's own checksummed go.mod (served by the Go module proxy) declares the module path as github.com/scip-code/scip-go.
# Indexer — single 17MB static Go binary, uses go/packages (the real Go typechecker)
go install github.com/scip-code/scip-go/cmd/scip-go@latest # → ~/go/bin/scip-go (v0.2.7)go install github.com/scip-code/scip/cmd/scip@latest fails: the scip repo's go.mod contains replace directives, which Go forbids for go install of a main package. Two options:
-
(used here, recommended for our Python ingester's equivalent) read the protobuf directly via the SCIP Go bindings — a ~20-line program (see Appendix A). The bindings (
github.com/scip-code/scip/bindings/go/scip, v0.8.0) are pulled in transitively byscip-go, so they're already cached. - clone-build the CLI:
git clone https://github.com/scip-code/scip && cd scip && go build ./cmd/scip(clone-build honors thereplacedirectives that blockgo install).
For the real ingester (Python, in
rag-service/) we won't use the Go bindings — we'll parse the protobuf via Python. ThescipCLI'sscip print --jsonis the easiest cross-language path; bundle a builtscipbinary, or generate Python protobuf bindings fromscip.proto.
/tmp/scip-spike/ — two packages, deliberately exercising every edge type:
-
shapes/shape.go:Shapeinterface (Area() float64),Circlestruct (R float64) with methodArea()that implicitly satisfiesShape(Go has noimplementskeyword —scip-gomust infer it). -
main.go:describe(s shapes.Shape)callss.Area()(interface call);main()constructsCircle, callsdescribe, callsfmt.Println(stdlib call).
This covers: package/type/method/field vertices, cross-package reference, concrete call, interface call, stdlib call, and interface satisfaction at both type and method level.
scip.Index (protobuf) = { metadata, documents[], external_symbols[] }.
-
metadata:tool_info {name:"scip-go", version:"0.2.7"},project_root: file:///tmp/scip-spike,text_document_encoding: UTF8. -
documents[]: one per source file —{ language, relative_path, occurrences[], symbols[] }. -
external_symbols[]: empty here — stdlib (fmt.Println) was referenced but not given aSymbolInformationentry (we didn't indexfmt). Important consequence below.
Grammar: <scheme> <manager> <package> <version> <descriptor>. Real examples:
scip-go gomod scipspike . scipspike/describe().
scip-go gomod scipspike . `scipspike/shapes`/Circle#Area().
scip-go gomod scipspike . `scipspike/shapes`/Shape#
scip-go gomod scipspike . `scipspike/shapes`/Circle#R.
-
versionis.for the indexed module itself (local); dependencies carry real versions. → normalization note in §5. -
The descriptor encodes scope + kind via suffix:
Suffix Meaning Example /package / namespace `scipspike/shapes`/#type (struct/interface) Circle#,Shape#().function / concrete method describe().,Circle#Area()..term — field or interface method spec Circle#R.,Shape#Area.⚠️ A field ref and an interface-method-spec ref both end in.— you cannot tell them apart by suffix alone. Use thekindfield (below).
The full moniker string is globally unique and stable → it is the MERGE key for idempotent loading into AGE.
Every SymbolInformation from scip-go carries a kind: Package, Struct, Interface, Method, MethodSpecification, Function, Field, Variable. (MethodSpecification = an interface's method declaration, distinct from a concrete Method.)
⚠️ AMENDED in Part 2:kindis NOT populated byscip-typescript@0.4.0orscip-python@0.6.6— both leave it empty. Sokindis not a portable vertex-type signal. The ingester must derive vertex type from the descriptor suffix (the SCIP-standard grammar in §3.1), usingkindonly as an enhancement when present (notably to identify Go'sMethodSpecification, which the suffix alone can't distinguish from a field). See Part 2.
{ range, symbol, symbol_roles, enclosing_range? }:
-
range:[startLine, startChar, endChar](single-line) or[startLine, startChar, endLine, endChar]. The definition range is the name token only, not the body. -
symbol_roles: bitmask. Observed: Definition = 1, ReadAccess = 8. (Full set: Import=2, WriteAccess=4, Generated=16, Test=32, ForwardDefinition=64.) -
enclosing_range: present on definition occurrences of functions/methods — the full body span. e.g.describe()name token[11,5,13]butenclosing_range [11,0,13,1]. This is the clean basis for caller attribution (§4.3).
SymbolInformation.relationships[] with booleans is_implementation / is_reference / is_type_definition / is_definition. scip-go emits is_implementation at both levels:
Circle# -[is_implementation]-> Shape# (struct implements interface)
Circle#Area(). -[is_implementation]-> Shape#Area. (method implements method spec)
Go's implicit interface satisfaction is captured precisely — no source annotation needed.
SCIP kind
|
AGE label | Key |
|---|---|---|
| Package | Package |
moniker |
| Struct, Interface |
Type (prop kind) |
moniker |
| Function, Method |
Function (prop method=bool) |
moniker |
| MethodSpecification |
Function (prop interface_method=true) |
moniker |
| Field | Field |
moniker |
Variable / local N
|
skip (function-scoped, not globally addressable) | — |
Each vertex also stores: file_path, range (name token, for the "clickable" jump), enclosing_range (for functions). File vertices come from documents[].relative_path.
-
CONTAINS: from descriptor nesting —
Package→Type→Method/Field. (Parse the descriptor, or use the document + scope.) AlsoFile—CONTAINS→ the symbols it defines (definition occurrences). -
DEFINES:
File→ symbol, from definition occurrences (role & 1). -
REFERENCES: symbol → symbol, from read occurrences (
role & 8) — the general "X is used at this location" edge.
For each reference occurrence whose symbol is callable, find the definition whose enclosing_range contains the reference position → that definition is the caller.
- "callable" =
kind ∈ {Function, Method, MethodSpecification}, or (kind unknown, e.g. stdlib) descriptor ends in().. -
enclosing_rangemakes this exact — no body-span approximation needed.
Validated output on the synthetic module:
describe() ──CALLS──▶ Shape#Area. (MethodSpecification — interface call)
main() ──CALLS──▶ Println(). (EXTERNAL/stdlib — kind unknown, suffix fallback)
main() ──CALLS──▶ describe(). (Function)
From is_implementation relationships → Type -[IMPLEMENTS]-> Type and Function -[IMPLEMENTS]-> Function(interface_method).
Key interaction: an interface call lands on the method spec, not the concrete method. The concrete target is reached by traversing IMPLEMENTS in reverse:
describe ──CALLS──▶ Shape#Area. ◀──IMPLEMENTS── Circle#Area().
→ "what concrete code can run when describe calls Area()?" = MATCH (caller)-[:CALLS]->(spec)<-[:IMPLEMENTS]-(concrete). Store both edges; let Cypher resolve dispatch at query time.
- Interface dispatch indirection (§4.4) — CALLS to an interface never points at concrete code directly. The graph must carry IMPLEMENTS to resolve it. Don't try to "flatten" dispatch at ingest; keep it a traversal.
-
enclosing_rangeis the caller-attribution mechanism — use it, do not approximate caller bodies from name-token ranges. -
External / cross-package callees have no
SymbolInformationunless those packages are indexed.fmt.Printlnappeared only as an occurrence symbol (nokind, no relationships). Decision needed: (a) create lightweight stubExternalvertices keyed by moniker so CALLS edges have a target, vs (b) only edge to indexed symbols and drop external calls. Recommend (a) — stubs preserve "calls into stdlib/deps" signal cheaply. -
Field vs interface-method-spec ambiguity — both end in
.; disambiguate viakind, never the suffix. -
versionis.for the indexed module, real for deps — when MERGE-keying on the moniker, the local module's.is stable per-index but two repos that both index a shared dep will produce the dep's symbols with matching versioned monikers (good — they dedupe). Worth a normalization pass if we ever rewrite the local.to a repo identifier. -
local Nsymbols (function-scoped locals) are not globally addressable — skip as vertices. -
Idempotent load:
MERGEon the full moniker string. Re-indexing a repo updates in place. (Pairs with the AGEcode_graphbootstrap already inschema/004_age.sql.)
The moniker grammar, occurrence/role model, enclosing_range, and relationships are SCIP-standard, not Go-specific — so scip-python and scip-typescript emit the same shape, and one ingester consumes all three. What varies per indexer and must be re-validated:
-
schemediffers (scip-python,scip-typescript) — fine, it's part of the key. -
kindcompleteness and relationship support may differ — e.g. Python's structural/duck typing means interface-styleis_implementationwill be sparser than Go's; TS has its ownimplements/extends. -
enclosing_rangeemission must be confirmed per indexer (Go has it; verify for the others before relying on it for CALLS).
Validation spikes:
- ✅
scip-python@0.6.6— validated on a synthetic module (Part 2). - ✅
scip-typescript@0.4.0— validated on a synthetic module (Part 2). - ⏳ Real-repo scale, still pending:
scip-goon~/louis14(clean Go) and~/mazzy(mixed Go+Python → two indexers, two language-islands; cross-language calls are invisible to SCIP);scip-pythononrag-service/;scip-typescripton~/lyos/mobile-v2.
Then: draft the concrete AGE schema migration + the Python ingester (scip print --json → batched Cypher MERGE), and wire it next to the existing pgvector ingestion in rag-service/.
Date: 2026-06-02 · Same synthetic module shape (Shape interface/ABC + Circle impl + describe caller) re-authored in Python and TypeScript, indexed and read with the same Go reader (Appendix A) — proving the reader and the planned ingester are genuinely language-neutral at the protobuf layer.
-
scip-typescript@0.4.0— npm@sourcegraph/scip-typescript. Run:scip-typescript index(readstsconfig.json). -
scip-python@0.6.6— npm@sourcegraph/scip-python. Run:scip-python index . --project-name N --project-version V --output index.scip.
Note: the npm packages did NOT migrate to
scip-code— they're still@sourcegraph/*. Only the Go repos (scip-go,scip) moved orgs.
| Capability | scip-go | scip-typescript | scip-python |
|---|---|---|---|
Moniker grammar <scheme> <mgr> <pkg> <ver> <descriptor>
|
✅ | ✅ | ✅ |
enclosing_range on definitions (→ CALLS works) |
✅ | ✅ | ✅ |
symbol_roles (Definition=1, Read=8) |
✅ | ✅ | ✅ |
is_implementation relationships |
✅ implicit-iface | ✅ explicit implements
|
✅ inheritance (incl. stdlib abc.ABC) |
| CALLS via enclosing-range enclosure | ✅ | ✅ | ✅ |
So one ingester consumes all three. CALLS, IMPLEMENTS, and the stable-moniker key all transfer.
-
kindis scip-go-only. TS and Python emit nokind→ vertex type must come from the descriptor suffix (/=pkg/module,#=type,().=callable,.=term,().(x)=parameter,:=Python module).kindis a Go-only bonus. -
Interface/abstract-method descriptor suffix differs:
- Go:
Shape#Area.(suffix.,kind=MethodSpecification) — a bare().-suffix callee filter misses Go interface calls. - TS/Python:
Shape#area().(suffix()., same as concrete). -
Portable callable rule:
descriptor.endswith('().') OR kind ∈ {Function, Method, MethodSpecification, Constructor}.
- Go:
-
Moniker grammar specifics (all parse the same way, but the pieces differ):
- Go:
scip-go gomod scipspike .+`scipspike/shapes`/Circle#Area(). - TS:
scip-typescript npm scip-ts-spike 1.0.0+`shapes.ts`/Circle#area().(the file is the namespace; constructor =Circle#`<constructor>`().) - Python:
scip-python python scippyspike 0.0.1+shapes/Circle#area().(the module is the namespace; module symbol =shapes/__init__:; constructor =Circle#__init__().)
- Go:
-
external_symbolspopulation is inconsistent. scip-python emits them (stdlib refs get real versioned monikers, e.g.scip-python python python-stdlib 3.11 abc/ABC#); scip-go and scip-typescript emitted 0 for these tiny projects. → The "stubExternalvertex keyed by moniker" decision (Part 1 §5.3) is required, not optional, for consistent cross-package CALLS targets. -
Python decorators surface as CALLS.
Shape#area().→abstractmethod().appeared because@abstractmethodsits inside the method'senclosing_range. Decide whether to keep (captures decoration) or filter (a decorator isn't a runtime call). Likely: tag these edges (via_decorator=true) rather than drop. -
Module-/top-level calls have no function caller. TS
console.log(describe(new Circle(2)))at file scope produced no CALLS edge (nothing encloses it as a function). To capture "package-level" calls, attribute them to theFile/Modulevertex instead of dropping. -
document.languageis set by scip-go but left empty by TS/Python — don't rely on it;metadata.tool_info.nameis the reliable language signal.
- §3.2 "kind is authoritative" → kind is scip-go-only; derive type from the descriptor suffix (done inline above).
- §4.3 callable filter → must be
().-suffix ORkind-based (Go method specs); see divergence #2. - §5.3 external stub vertices → upgraded from "recommended" to required (divergence #4).
- New gotcha: decorators-as-CALLS (#5); module-level calls (#6).
The SCIP model is portable enough that a single descriptor-suffix-driven ingester handles Go, Python, and TypeScript, deriving vertices from suffixes, CALLS from enclosing_range, and IMPLEMENTS from is_implementation. The per-indexer quirks (no kind, Go's .-suffix method specs, decorator calls, external-symbol variance) are all handled by the rules above — none require per-language ingester branches beyond the callable-detection rule. Real-repo scale validation (large indexes, .tsx, mixed-language ~/mazzy) is the remaining unknown before building the ingester.
Date: 2026-06-02 · Indexed louis14 (module louis14, go 1.26.2, 343 tracked .go / 26 source packages) via a sibling git worktree in ~ (~/louis14-scip-wt) so the cross-module replace mazarin/textshape => ../mazzy/... still resolved (a /tmp clone would have broken it). scip-go v0.2.7.
| Metric | Value | Implication |
|---|---|---|
| scip-go runtime | 3.0s wall (17s CPU, parallel) | performance is a non-issue |
index.scip |
15 MB (~44 KB/file) | → 58 MB JSON; converts in 0.4s, analyzes in 0.8s |
| documents | 318 (incl. 105 _test.go) |
deps are NOT emitted as docs; tests ARE |
| symbols | 39,654 — but 32,501 locals (82%) | filter locals → ~7.1K real vertices |
| occurrences | 234,646 | the edge-source data |
external_symbols[] |
0 | scip-go emits none, even at scale |
is_implementation |
259 | IMPLEMENTS edges |
| CALLS (reconstructed) | 24,424 | real call graph |
| — closure-nested call sites | 0 | enclosure is unambiguous — no innermost-match needed |
| — calls to external/undefined | 8,035 (33%) | external stubs required |
kind populated |
39,651 / 39,654 | reliable for Go (still Go-only) |
generics ([...] monikers) |
0 | not exercised by this repo — still open |
-
External stub vertices are REQUIRED (Go).
external_symbols[]is empty, yet 33% of calls target external code, visible only as occurrence monikers — which are clean and versioned, so stubbing is mechanical:- stdlib →
scip-go gomod github.com/golang/go/src go1.26.2 flag/Int().(6,909 calls) - third-party →
… github.com/dop251/goja …(370),github.com/fogleman/gg(254),golang.org/x/text(39) - cross-module →
mazarin/textshape …(468) — the~/mazzydep, captured only because the sibling worktree preserved../mazzy -
Ingester:
MERGE (:Symbol {external:true})per unique external moniker; parse package+version from the moniker.
- stdlib →
-
The enclosure CALLS algorithm needs no innermost-match (for Go). 0 of 24,424 call sites had >1 enclosing caller — scip-go emits no
enclosing_rangefor anonymous closures, so closure-body calls attribute to the enclosing named function. Plain containment is correct. -
Locals are 82% of symbols — filter them (
local N, kindVariable). Real graph for louis14 ≈ 7.1K vertices + ~24K CALLS + 259 IMPLEMENTS + references. Small enough that AGE batching is a convenience, not a necessity, at single-repo scale. -
Dependencies have no internal structure —
third_party/gg,mazzy/textshapeare referenced by moniker but not emitted asdocuments. The graph has full detail only for project code; deps/stdlib are stub-only unless separately indexed. This is the graph's natural, bounded edge. -
Test code is in the index (105
_test.godocs). Tagtest:trueon test-origin vertices/edges so queries can include or exclude (recommend include + tag).
-
Generics — louis14 has none; need a generics-heavy repo to see how type parameters encode in monikers (does
[T]threaten the MERGE key?). -
.tsx/ large TS —~/lyos/mobile-v2. -
Mixed-language
~/mazzy(Go+Python) — two indexers → two disconnected sub-graphs; cross-language calls (Go→Python) are invisible to SCIP.
Net: at real single-repo scale the model and reconstruction hold, performance is trivial, and the one hard requirement is external stub vertices. Ready to design the AGE schema + Python ingester.
package main
import (
"fmt"; "os"
"github.com/scip-code/scip/bindings/go/scip"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/proto"
)
func main() {
data, _ := os.ReadFile(os.Args[1])
var idx scip.Index
proto.Unmarshal(data, &idx)
out, _ := protojson.MarshalOptions{Indent: " ", UseProtoNames: true}.Marshal(&idx)
fmt.Println(string(out))
}go install github.com/scip-code/scip-go/cmd/scip-go@latest
cd <go-module-root> && ~/go/bin/scip-go index # → index.scip (protobuf)
# read it:
cd /tmp/scip-reader && go mod tidy && go run . <path>/index.scip > index.jsonVersions at spike time: scip-go v0.2.7, scip bindings v0.8.0, Go 1.26.2, darwin/arm64.