Conversation
extract_elixir mints module definitions as _make_id(file_stem, name) but emitted alias/import references as _make_id(name). The two can never be equal, so build's node-set prune dropped every Elixir alias/import edge: 2199 emitted, 0 surviving on a 400-file sample. An Elixir alias names a BEAM module, not a path, so the per-file extractor cannot resolve it. Same shape as Kotlin Graphify-Labs#2526, fixed the same way: stamp the written name as metadata.target_fqn and repoint the edge in a corpus-wide resolver, guarded on a single candidate so nothing is fabricated. Definition ids are unchanged, so existing graphs keep their node ids. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
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.
Formal verification. 2 change(s) alter behavior, breaking input(s) attached.
Behavior changes: \_\_getattr\_\_ changes behavior, here is the input that shows it.
The verifier found a concrete input on which \_\_getattr\_\_ behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"name":"'save\_query\_result'"\}, the old code produced raises AttributeError but the new code produces \<function save\_query\_result at 0x7f67338a8400\>. Paste that input straight into a regression test.
Behavior changes: run\_benchmark changes behavior, here is the input that shows it.
The verifier found a concrete input on which run\_benchmark behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph\_path":"None","corpus\_words":"None","questions":"None"\}, the old code produced raises TypeError but the new code produces \{'corpus\_tokens': 1099466, 'corpus\_words': 824600, 'nodes': 16492, 'edges': 31042, 'avg\_query\_tokens': 17027, 'reduction\_ratio': 64\.6, 'per\_question': \[\{'question': 'how does authentication…. Paste that input straight into a regression test.
Graphify review — findings
Adds full CI/CD and repo hygiene scaffolding: a CI workflow running skillgen drift checks, a 3.10–3.14 pytest matrix, end-to-end install verification, and non-blocking bandit/pip-audit scans; a trusted-publishing publish.yml gated on tag/version match; and a release-graph.yml that builds an AST-only self-graph asset per release. Adds .dockerignore, .gitattributes (marking generated HTML as vendored so Linguist reports Python), FUNDING config, a ruff + skillgen pre-commit config, AGENTS.md, and expands .gitignore while keeping generated graphify/skills/ and skillgen fragments tracked.
Worth a look
- Unpinned PyPI publish actions run with OIDC publishing permission —
.github/workflows/publish.yml:31· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- HTTP server image defaults to no API key —
Dockerfile:25· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Release asset workflow uses unpinned actions with write token —
.github/workflows/release-graph.yml:12· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Build copies entire context into image —
Dockerfile:12· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- README gives conflicting default contract for query logging —
README.md· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 16151 functions depend on the 16151 functions this change touches.
Health — this change adds coupling hotspots:
- new:
extract()— 599 callers, 44 callees - new:
_rebuild_code()— 116 callers, 51 callees - new:
build_from_json()— 207 callers, 19 callees - new:
deduplicate_entities()— 76 callers, 24 callees - new:
detect()— 112 callers, 15 callees - new:
build_merge()— 76 callers, 14 callees - new:
to_obsidian()— 38 callers, 14 callees - new:
save_semantic_cache()— 58 callers, 9 callees - …and 235 more — each is listed as a finding
Verification — 16151 functions in the blast radius were not formally verified this run (proofs are advisory here).
Health delta baseline: last indexed commit fe66389 (diverged from this PR's base — delta is approximate).
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: 16151 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
276 of 276 test file(s) selected (100%) via static blast radius.
Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.
tests/test_affected_cli.py— impact, changed-test, full-run-safetytests/test_affected_member_seed.py— impact, changed-test, full-run-safetytests/test_agents_platform.py— impact, changed-test, full-run-safetytests/test_analyze.py— impact, changed-test, full-run-safetytests/test_anthropic_custom_endpoint.py— changed-test, full-run-safetytests/test_antigravity_install.py— changed-test, full-run-safetytests/test_apm_fallback_version.py— impact, changed-test, full-run-safetytests/test_architecture_doc.py— impact, changed-test, full-run-safetytests/test_astro_extraction.py— impact, changed-test, full-run-safetytests/test_astro_import_ids.py— impact, changed-test, full-run-safetytests/test_atomic_canvas_export.py— impact, changed-test, full-run-safetytests/test_atomic_version_stamp.py— impact, changed-test, full-run-safetytests/test_atomic_writes.py— impact, changed-test, full-run-safetytests/test_backend_env_isolation.py— impact, changed-test, full-run-safetytests/test_backend_extras.py— impact, changed-test, full-run-safetytests/test_benchmark.py— impact, changed-test, full-run-safetytests/test_benchmark_raw_graph.py— impact, changed-test, full-run-safetytests/test_build.py— impact, changed-test, full-run-safetytests/test_build_merge_dedup_scope.py— impact, changed-test, full-run-safetytests/test_build_merge_hyperedges_and_prune.py— impact, changed-test, full-run-safetytests/test_build_merge_shrink_guard.py— impact, changed-test, full-run-safetytests/test_builtin_global_type_refs.py— impact, changed-test, full-run-safetytests/test_cache.py— impact, changed-test, full-run-safetytests/test_callflow_html.py— impact, changed-test, full-run-safetytests/test_cargo_introspect.py— impact, changed-test, full-run-safetytests/test_carried_hyperedge_remap.py— impact, changed-test, full-run-safetytests/test_case_sensitive_resolution.py— impact, changed-test, full-run-safetytests/test_charmap_encoding.py— impact, changed-test, full-run-safetytests/test_chunking.py— impact, changed-test, full-run-safetytests/test_cjs_module_extension.py— impact, changed-test, full-run-safetytests/test_claude_cli_backend.py— impact, changed-test, full-run-safetytests/test_claude_md.py— impact, changed-test, full-run-safetytests/test_cli_broken_pipe.py— changed-test, full-run-safetytests/test_cli_export.py— impact, changed-test, full-run-safetytests/test_cli_help.py— changed-test, full-run-safetytests/test_cluster.py— impact, changed-test, full-run-safetytests/test_codebuddy.py— impact, changed-test, full-run-safetytests/test_community_hub_labels.py— impact, changed-test, full-run-safetytests/test_community_labels_skill.py— impact, changed-test, full-run-safetytests/test_confidence.py— impact, changed-test, full-run-safetytests/test_corrupt_graph_json.py— impact, changed-test, full-run-safetytests/test_cpp_nested_and_cli.py— impact, changed-test, full-run-safetytests/test_cpp_objc_cross_file_calls.py— impact, changed-test, full-run-safetytests/test_cpp_preprocess.py— impact, changed-test, full-run-safetytests/test_cross_extension_reexport_self_cycle.py— impact, changed-test, full-run-safetytests/test_cross_language_call_resolution.py— impact, changed-test, full-run-safetytests/test_cross_repo_member_calls.py— impact, changed-test, full-run-safetytests/test_cross_repo_shared_types.py— impact, changed-test, full-run-safetytests/test_csharp_call_site_generic_args.py— impact, changed-test, full-run-safetytests/test_csharp_enum_members.py— impact, changed-test, full-run-safety- … and 226 more
non-code file(s) changed (
.dockerignore,.gitattributes,.github/FUNDING.yml,.github/workflows/ci.yml,.github/workflows/publish.yml…) → running the full suite for safety (a code graph can't see config/fixture/data deps)
changed code file(s) not in the graph (
tests/eval_attention.py,tests/fixtures/sample.dm) — deleted or unindexed, so their dependent tests can't be found; running the full suite for safety
changed code file(s) with no mapped test (
.github/workflows/ci.yml,.github/workflows/publish.yml,.github/workflows/release-graph.yml,AGENTS.md,ARCHITECTURE.md…) — 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.
Formal verification
Behavior changes: \_\_getattr\_\_ changes behavior, here is the input that shows it.
The verifier found a concrete input on which \_\_getattr\_\_ behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"name":"'save\_query\_result'"\}, the old code produced raises AttributeError but the new code produces \<function save\_query\_result at 0x7f67338a8400\>. Paste that input straight into a regression test.
Behavior changes: run\_benchmark changes behavior, here is the input that shows it.
The verifier found a concrete input on which run\_benchmark behaves differently before and after the change. If that change is intended, ship it; if not, this is your bug.
Guarantee: This difference was REPRODUCED, the verifier actually ran both versions on that input and saw them disagree. It is real, not an artifact.
Evidence: On input \{"graph\_path":"None","corpus\_words":"None","questions":"None"\}, the old code produced raises TypeError but the new code produces \{'corpus\_tokens': 1099466, 'corpus\_words': 824600, 'nodes': 16492, 'edges': 31042, 'avg\_query\_tokens': 17027, 'reduction\_ratio': 64\.6, 'per\_question': \[\{'question': 'how does authentication…. Paste that input straight into a regression test.
No difference found (not proven): No behavior difference found in main (not a proof).
The verifier ran both versions of main on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_cross\_community\_surprises (not a proof).
The verifier ran both versions of \_cross\_community\_surprises on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_cross\_file\_surprises (not a proof).
The verifier ran both versions of \_cross\_file\_surprises on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
No difference found (not proven): No behavior difference found in \_file\_category (not a proof).
The verifier ran both versions of \_file\_category on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify \_is\_file\_node.
The verifier did not have enough to check \_is\_file\_node, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Could not verify: Could not verify \_surprise\_score.
The verifier did not have enough to check \_surprise\_score, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)
No difference found (not proven): No behavior difference found in god\_nodes (not a proof).
The verifier ran both versions of god\_nodes on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify graph\_diff.
The verifier did not have enough to check graph\_diff, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: the input domain has 25 values but only 5 distinct were tested — a small finite domain must be EXHAUSTED, not sampled (an untested input could invert the result)
Could not verify: Could not verify suggest\_questions.
The verifier did not have enough to check suggest\_questions, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 200 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly ValueError — names the real obstacle, not a sampling gap)
No difference found (not proven): No behavior difference found in \_query\_subgraph\_tokens (not a proof).
The verifier ran both versions of \_query\_subgraph\_tokens on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify print\_benchmark.
The verifier did not have enough to check print\_benchmark, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly KeyError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify build.
The verifier did not have enough to check build, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 9 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly AttributeError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify build\_from\_json.
The verifier did not have enough to check build\_from\_json, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: not verifiable: all 6 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly NameError — names the real obstacle, not a sampling gap)
Could not verify: Could not verify cache\_dir.
The verifier did not have enough to check cache\_dir, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify cached\_files.
The verifier did not have enough to check cached\_files, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify check\_semantic\_cache.
The verifier did not have enough to check check\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify clear\_cache.
The verifier did not have enough to check clear\_cache, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify file\_hash.
The verifier did not have enough to check file\_hash, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify load\_cached.
The verifier did not have enough to check load\_cached, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify save\_cached.
The verifier did not have enough to check save\_cached, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set
Could not verify: Could not verify save\_semantic\_cache.
The verifier did not have enough to check save\_semantic\_cache, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set
No difference found (not proven): No behavior difference found in \_split\_community (not a proof).
The verifier ran both versions of \_split\_community on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.
Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.
Note: An input the sampler did not try could still differ.
Could not verify: Could not verify cluster.
The verifier did not have enough to check cluster, so it is saying so rather than guessing. No false assurance is the whole point.
Guarantee: No guarantee either way, this is an honest abstention, not a pass.
Note: Reason: non-vacuity: domain too small (only 2 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous
Fixes #3565.
Root cause
extract_elixirmints module definitions as_make_id(file_stem, module_name)— the corpus-wide invariant documented on_file_stem(#1504), which keeps same-named symbols in different files from collapsing — but emitted the reference side ofalias/import/require/useas_make_id(module_name), with no stem. The two ids can never be equal, sobuild_from_json'ssrc not in node_set or tgt not in node_setprune dropped every Elixir alias/import edge.An Elixir alias names a BEAM module, not a path, so the per-file extractor cannot resolve it, and
#2195'starget_fileprobing does not apply. This is the same shape as Kotlin #2526 and is fixed the same way.The fix
extractors/elixir.pystamps the written name asmetadata.target_fqn, and marks top-leveldefmodulenodes with_elixir_module.extract.pyadds_resolve_elixir_import_targets, a corpus-wide pass mirroring_resolve_kotlin_import_targets: a name defined exactly once is repointed onto that node; zero or several candidates are left dangling, so no stub node is ever fabricated and confidence staysEXTRACTED(the label is the alias text as written, so the match is exact).Definition ids are unchanged — existing graphs keep their node ids. This is why the resolver approach is preferable to making the definition id name-derived, which would churn every Elixir module id in every stored graph.
Measured
980-file Phoenix/Ecto app,
lib/only:importsemitted / surviving buildcontainsedgesWhole repo (2,470
.ex/.exs): 0 → 6,390 / 8,570 (74.6%). Unresolved remainder is genuine externals (Logger,Ecto.Query, deps).Suite: 5,635 passed, 1 failed, 14 skipped. The failure is
test_export.py::test_hyperedge_convex_hull_js_is_geometrically_sound, which fails identically on cleanHEAD— pre-existing and environmental, not from this change.Three guards, each with a regression test
Adversarial review found these; each is covered in
tests/test_elixir_import_ids.py:Self-import must not destroy
contains. The__using__/quote do import MyApp.Context endidiom makes a file alias a module it also defines. Retargeting lands the edge on the same(source, target)pair asfile --contains--> module; the built graph is a plain non-multiGraph, so one relation silently overwrites the other. Measured at 17 lostcontainsedges before the guard. Self-imports carry no cross-file information and are now skipped.Nested
defmodulemust not capture a foreign alias.extract_elixirlabels a nested module with the name as written —defmodule SupervisorinsideMyApp.Applicationis labelledSupervisor, notMyApp.Application.Supervisor. Indexing it would let it absorbuse Supervisor(the stdlib module) from unrelated files and assert a falseEXTRACTEDedge. Only top-level defmodules are indexed, via_elixir_module. That marker also excludes any same-labelled node a semantic tier might add, which would otherwise make the name ambiguous and silently suppress resolution.Resolution must survive an incremental rebuild. The early resolver slot was handed
all_nodes, which on a partial re-extract holds only the changed batch — so every alias into an unchanged file would dangle and be pruned, decaying the graph back toward the bug on exactly thegraphify watch/ hook path. Worse, it bypassed the single-candidate guard: a corpus-wide-ambiguous name can look unique within the changed subset and get an arbitrary target. Now indexesresolution_nodes, which includes the unchanged-corpus context (Incremental update loses cross-file edges from changed→unchanged files (batch-scoped import resolution + replace-on-re-extract) #2406) — matching that variable's own documented scope ("the member-call resolvers run byrun_language_resolvers"). Inert for Kotlin, which indexes offper_file.Risks / compatibility
_AST_CACHE_SCHEMA2 → 3. The extractor's output changed (addsmetadata.target_fqn), so a cache written by an earlier build would keep serving pre-fix results and make this change silently inert — verified: fix + pre-fix cache reproduces 0% resolution with no warning. The bump sweeps those entries. This also traps anyone re-measuring against a warm cache..exs,defimpl, nested and reopened modules).graphify update --no-clusterwrites the raw extraction, so itsgraph.jsonstill carries some danglingimports(7 post-fix vs 16 pre-fix on the repro). Pre-existing behaviour, reduced but not eliminated.Known limitation
This restores the module dependency layer only.
extract_elixiremits no cross-module call edge in any style — neitherBar.run(x)after an alias nor fully-qualifiedMyApp.Foo.Bar.run(x)produces one — so the graph gains module-to-module imports, not a call graph. Noted in #3565.Also noted in #3565: the same definition/reference id asymmetry reproduces with zero surviving cross-file edges in ~12 other extractors (Rust, Fortran, Julia, Apex, Verilog, Razor, Blade, Dart, Scala, Groovy, Zig, Pascal). Out of scope here; happy to follow up.