Skip to content

feat(scip): ingest real SCIP indexes (protobuf), not only SCIP-style JSON - #5070

Open
LHopitalOne wants to merge 3 commits into
Graphify-Labs:v8from
LHopitalOne:feat/scip-protobuf-ingest
Open

LHopitalOne wants to merge 3 commits into
Graphify-Labs:v8from
LHopitalOne:feat/scip-protobuf-ingest

Conversation

@LHopitalOne

@LHopitalOne LHopitalOne commented Nov 6, 2026 •

Copy link
Copy Markdown

What does this PR do?

Follows up on the split you asked for when closing #783: SCIP ingest (scip_ingest.py) as its own focused PR,
on current v8.

scip_ingest.ingest_scip_json reads a simplified, LLM-style SCIP JSON shape (its docstring says so). The
index.scip files that real indexers write (scip-python, scip-typescript, scip-java, rust-analyzer, ...) are
protobuf, keep occurrences on the document rather than on each symbol, and use zero-based lines, so they could not be
ingested. This PR makes them ingestible:

  • graphify/scip_index.py (new): decode_scip_index(data) reads the SCIP protobuf wire format directly, with no
    new dependency, into the JSON shape scip print --json prints, typed as ScipIndex (a TypedDict tree:
    ScipDocument, ScipOccurrence, ScipSymbol, ScipRelationship, ScipMetadata).
    • Reads both the deprecated packed range and the typed single_line_range / multi_line_range that SCIP 0.4+
      writes (typed wins, as scip.proto specifies). Handles proto3's omitted zero fields.
    • Skips unknown fields, so newer indexes still decode.
    • Raises ScipDecodeError on a damaged index instead of guessing (truncated varint, a length past its message, an
      unknown wire type, a number where scip.proto has a string or message, or the reverse).
    • symbol_display_name() labels a symbol from its last descriptor: Square, area(), matching how the AST
      extractors label callables.
  • scip_ingest.ingest_scip_index(path | bytes) (new entry point): decodes, then maps onto the existing shape and
    runs the existing ingest_scip_json passes unchanged, returning ScipIngestResult:
    • each document's occurrences move under the symbols it defines, definition first, so a node's source_location
      is its definition line;
    • zero-based SCIP lines become Graphify's one-based ones (L1 is the first line);
    • document-local symbols (local 12) are left out: no identity outside their file, no name;
    • oversized, unreadable or malformed input returns {"nodes": [], "edges": [], "error": ...} like
      extract_mcp_config, and honours GRAPHIFY_MAX_GRAPH_BYTES.

What it deliberately does not do (yet)

  • Reference edges. A reference occurrence does not say which symbol it sits in unless the indexer writes
    enclosing_range, and most do not. Turning references into edges would mean guessing the enclosing symbol, so this
    PR leaves them out rather than add relationships the index does not state.
  • CLI / corpus wiring. .scip files are not picked up by detect/extract here; that touches binary-file
    handling and the caches, so it seemed better as a separate PR. Happy to do it next in whichever form you prefer
    (auto-detecting *.scip in the corpus, or an explicit --scip index.scip flag).

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Verification & Invariants

Invariants: the existing ingest_scip_json path and its output are untouched (the new entry point only adds a decode
and reshape step in front of it); a node's source_location is its definition line, one-based; a damaged or oversized
index yields an error result, never an exception or a partial graph; the same index always gives the same graph.

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant (a real index.scip could not be read at all).
  • Made the smallest fix necessary (one new module, one new entry point, no new dependency).
  • Added a regression test (if bug fix) or isolated boundary test.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly (see "What it deliberately does not do").

How was this tested?

uv sync --all-extras
uv run pytest tests/test_scip_index.py tests/test_scip_ingest.py   # 120 passed
uv run ruff check graphify/scip_index.py graphify/scip_ingest.py tests/test_scip_index.py
uv run ruff format --check graphify/scip_index.py graphify/scip_ingest.py tests/test_scip_index.py
pyright graphify/scip_index.py graphify/scip_ingest.py             # 1.1.409, 0 errors
uv run pytest tests                                                 # 10,205 passed, 29 skipped, 12 failed (see below)

# The CI steps from ci.yml, on the pushed commit in a clean checkout:
uv run --frozen --python {3.10,3.12,3.13,3.14} pytest tests/test_scip_index.py tests/test_scip_ingest.py   # 120 passed on each
uv run --frozen python -m tools.skillgen --check                    # and --audit-coverage, --schema-singleton,
                                                                    # --monolith-roundtrip, --always-on-roundtrip: all OK
uv run --frozen bandit graphify/scip_index.py graphify/scip_ingest.py -ll   # no issues
uv run --frozen graphify --help                                     # OK
  • tests/test_scip_index.py (29 tests): both range encodings, typed range precedence, proto3 zero omission, unpacked
    repeated ranges, symbol information and relationship flags, external symbols, unknown and fixed-width fields,
    malformed input (including wrong wire types), labels for Python / Rust / TypeScript symbols, definition lines, relationship edges, path vs bytes,
    the error results, determinism.
  • A real index: tests/fixtures/scip/shapes.scip (3.7 KB), written by scip-python 0.6.8 from the 18-line
    shapes.py next to it (the command is in the test). It checks the nodes land on their definition lines and that
    scip-python's two is_implementation relationships (Square → Shape, Square.area → Shape.area) become
    scip_impl edges.
  • The existing tests/test_scip_ingest.py is unchanged and passes.
  • Full suite: I first ran it in a shallow clone, where 12 skillgen tests (test_skillgen.py,
    test_skill_no_backtick_substitution.py) failed because the commits they audit were missing. With full history all
    but one pass. The remaining one,
    test_skill_no_backtick_substitution.py::test_control_a_backticked_unknown_command_is_caught, fails the same way on
    the untouched v8 base (16218bf) on my machine, so it is not from this change.
  • pip-audit --strict reports advisories for pypdf 6.21.2, which comes from the committed uv.lock. This PR does not
    touch the lock, so the audit job will likely flag it on v8 as well.

Also run on real indexes of large repositories, outside the test suite:

Index Written by Nodes Edges Time Peak memory
getsentry/relay rust-analyzer 18,897 0 0.8 s 250 MB
getsentry/snuba scip-python 28,706 2,919 1.4 s 280 MB
getsentry/sentry (266 MB) scip-python 282,054 12,433 14.9 s 2.4 GB

(rust-analyzer writes no relationships when run without build scripts, hence 0 edges for relay.)

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. (n/a: no skill fragments changed)
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables). (the only env read is the existing GRAPHIFY_MAX_GRAPH_BYTES size cap)
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

🤖 Generated with Claude Code

…JSON

scip_ingest.py could read only a simplified, LLM-style SCIP JSON shape; the
index.scip files that scip-python, scip-typescript, rust-analyzer and other
indexers write were out of reach. This adds:

- graphify/scip_index.py: decode_scip_index() reads the SCIP protobuf wire
  format directly (no new dependency) into the JSON shape `scip print --json`
  uses. It reads the deprecated packed `range` and the typed
  single_line_range / multi_line_range of SCIP >= 0.4 (typed wins), skips
  unknown fields, and raises ScipDecodeError on a damaged index instead of
  guessing. symbol_display_name() labels a symbol from its last descriptor.
- scip_ingest.ingest_scip_index(path | bytes): decodes, moves each document's
  occurrences under the symbols it defines (definition first, zero-based SCIP
  lines turned into Graphify's one-based ones), leaves out document-local
  symbols, labels symbols without a display_name ('Square', 'area()'), then
  runs the existing ingest_scip_json passes. Oversized, unreadable or malformed
  input returns {"nodes": [], "edges": [], "error": ...} like
  extract_mcp_config, honouring GRAPHIFY_MAX_GRAPH_BYTES.

Reference occurrences are not turned into edges yet: without enclosing_range
an occurrence does not say which symbol it belongs to, and guessing would add
relationships the index does not state.

Tests cover both range encodings, proto3 zero omission, unknown fields,
malformed input, the error results and a real index written by
scip-python 0.6.8 (tests/fixtures/scip). The existing ingest_scip_json tests
are unchanged and pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown

Thanks for the pull request, @LHopitalOne. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

…ypes

decode_scip_index now returns ScipIndex, a TypedDict tree (ScipDocument,
ScipOccurrence, ScipSymbol, ScipRelationship, ScipMetadata) instead of
dict[str, Any], and ingest_scip_index returns ScipIngestResult. Raw field
values are typed int | memoryview | None, and reading one as the wrong kind
raises ScipDecodeError: before, a string field written as a varint would
have been decoded as zero bytes instead of rejected.

Same nodes and edges as before on the test fixtures and on real indexes of
getsentry/sentry, relay and snuba.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; no formal proof of the changed code.


Graphify review — findings

Adds a dependency-free protobuf reader, decode_scip_index, that turns a binary index.scip into the same JSON-shaped dict scip print --json emits, skipping unknown fields and preferring the typed single/multi-line ranges over the deprecated packed range. It raises ScipDecodeError on truncated varints, overlong lengths and unknown wire types, but the decoder trusts field wire types and sizes, so a mistyped field or oversized length can still surface as a raw TypeError or force a huge allocation instead of that clean error. symbol_display_name derives a short label from a symbol's last descriptor, and the ingest module in scip_ingest.py now accepts a real index and maps its relationships to graph edges, with fixtures and tests covering the shapes, malformed input and relationship edges.

Worth a look

  • Varint-typed string field makes _text allocate an attacker-chosen number of bytes — graphify/scip_index.py · Escalate · high
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Unbounded varint length allows quadratic CPU DoS — graphify/scip_index.py:315 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Known field with an unexpected wire type raises TypeError, not ScipDecodeError — graphify/scip_index.py:192 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • Valid protobuf with mistyped fields escapes the error contract and raises TypeError — graphify/scip_ingest.py:210 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • File size check can be bypassed before an unbounded read — graphify/scip_ingest.py:169 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 169 functions depend on the 84 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: ingest_scip_json() — 77 callers, 4 callees
  • new: decode_scip_index() — 12 callers, 5 callees
  • new: _shapes_index() — 5 callers, 6 callees
  • new: ingest_scip_index() — 7 callers, 4 callees
  • new: test_decode_skips_unknown_and_fixed_width_fields() — 0 callers, 7 callees
  • new: test_decode_typed_range_wins_over_the_deprecated_packed_one() — 0 callers, 6 callees

Verification — 169 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 169 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

527 of 527 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety (noncode-change, uncovered-code) — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • tests/pi.test.ts — full-run-safety
  • tests/test_4874_jvm_resolution.py — full-run-safety
  • tests/test_4909_reference_binding_scale.py — full-run-safety
  • tests/test_4978_reconcile_cost.py — full-run-safety
  • tests/test_4992_ts_import_types.py — full-run-safety
  • tests/test_affected_cli.py — full-run-safety
  • tests/test_affected_defines.py — full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_affected_overrides.py — full-run-safety
  • tests/test_affected_path_file_node.py — full-run-safety
  • tests/test_affected_seed_reference_forms.py — full-run-safety
  • tests/test_agents_platform.py — full-run-safety
  • tests/test_alias_folding.py — full-run-safety
  • tests/test_analyze.py — full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — full-run-safety
  • tests/test_astro_import_ids.py — full-run-safety
  • tests/test_atomic_canvas_export.py — full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_backend_opt_in.py — full-run-safety
  • tests/test_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_benchmark_real_query.py — full-run-safety
  • tests/test_binary_media_words.py — full-run-safety
  • tests/test_blade_extractor.py — full-run-safety
  • tests/test_build.py — full-run-safety
  • tests/test_build_located_semantic_identity.py — full-run-safety
  • tests/test_build_merge_dedup_scope.py — full-run-safety
  • tests/test_build_merge_ghost_fold_4955.py — full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — full-run-safety
  • tests/test_c_include_resolution.py — full-run-safety
  • tests/test_cache.py — full-run-safety
  • tests/test_cache_long_paths.py — full-run-safety
  • tests/test_cache_stale_import_target.py — full-run-safety
  • tests/test_cache_windows_case_4925.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_cargo_missing_manifest.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_only_decl_ids_4904.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — full-run-safety
  • … and 477 more

non-code file(s) changed (tests/fixtures/scip/shapes.scip) → running the full suite for safety (a code graph can't see config/fixture/data deps)

changed code file(s) with no mapped test (tests/fixtures/scip/shapes.py) — a coverage gap or a missing link — running the full suite rather than only the selected tests

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Risky patterns (advisory)

  • medium no-unbounded-fetch in graphify/scip_ingest.py:176: unbounded read (no limit/bound): data = handle.read()

Docs that may be stale (advisory)

…and 10 more.

· 5 grounded finding(s) anchored inline below; 1 more finding(s) on lines outside this diff (see the check run).

Comment thread graphify/scip_index.py
57: "TypeFamily",
58: "TypeParameter",
59: "Union",
60: "Value",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — decode_scip_index()

12 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/scip_ingest.py
ScipDecodeError,
ScipIndex,
ScipOccurrence,
ScipSymbol,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — ingest_scip_json()

77 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread tests/test_scip_index.py
assert [o["range"] for o in occurrences] == [[0, 4, 9], [2, 1, 5, 0]]


def test_decode_typed_range_wins_over_the_deprecated_packed_one() -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_decode_typed_range_wins_over_the_deprecated_packed_one()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread tests/test_scip_index.py
assert decoded["documents"] == []


def test_decode_skips_unknown_and_fixed_width_fields() -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_decode_skips_unknown_and_fixed_width_fields()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread tests/test_scip_index.py
# ---------------------------------------------------------------------------
# ingest_scip_index
# ---------------------------------------------------------------------------

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _shapes_index()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Two findings from the review bot, both reproduced first:

- A varint longer than protobuf's 10 bytes made _varint keep shifting a growing
  int, so decoding slowed quadratically (320 KB of 0xff took 9 s). It now
  raises ScipDecodeError after 10 bytes.
- ingest_scip_index checked os.path.getsize() and then read the whole file, so a
  FIFO or device that reports size 0 (e.g. /dev/zero) was read without end. It
  now reads at most GRAPHIFY_MAX_GRAPH_BYTES + 1 bytes and rejects anything longer.

The bot's other three findings (a string field holding a varint, and wrong wire
types raising TypeError) were fixed by the previous commit. A fuzz run of 30,000
corrupted indexes raised nothing but ScipDecodeError; slowest input 49 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant