Skip to content

feat(apex): parse Apex with tree-sitter, add calls edges - #3122

Open
rsoesemann wants to merge 2 commits into
Graphify-Labs:v8from
rsoesemann:feat/apex-tree-sitter
Open

feat(apex): parse Apex with tree-sitter, add calls edges#3122
rsoesemann wants to merge 2 commits into
Graphify-Labs:v8from
rsoesemann:feat/apex-tree-sitter

Conversation

@rsoesemann

@rsoesemann rsoesemann commented Aug 26, 2026

Copy link
Copy Markdown

The Apex extractor has never produced calls edges, and its docstring says why: no tree-sitter grammar on PyPI. That comment is out of date — aheber/tree-sitter-sfapex exists, is MIT, and was last touched a week ago.

It happens to use the same node type and field names as tree-sitter-java for declarations and calls, so the shared engine walk drives it from a config with no special cases. On my-org-butler, 55 Apex files: 592 calls edges where there were 0, across 225 distinct targets, the busiest with 17 incoming — so nothing turned into a god-node.

AgentMemory and what reaches it, in the graph graphify builds for that repo:

AgentMemory call structure

The whole repo. Clustering finds 38 communities where the same corpus without calls edges fell into 80 mostly disconnected stars — and because the call structure gives each community a real hub, the hub labels come out as class names rather than Community N:

my-org-butler communities

Not the Java grammar, though — that was the tempting shortcut and it is wrong. Parsing those same 55 files with tree-sitter-java yields 740 ERROR nodes across 53 of them, and it doesn't raise, it just returns a broken tree. The sfapex grammar parses all 55 with zero errors, including ?? (Spring '24), user-mode DML and WITH USER_MODE (Spring '23).

The regex extractor stays as the fallback, like Pascal's. Without the extra, Apex extracts exactly as before, minus calls. The full suite passes both ways: 4966 with the grammar, 4951 and 63 skipped without.

The dependency is the part worth arguing about. The grammar publishes bindings for npm, Cargo and WASM but nothing for Python, so tree-sitter-language-pack is the only route. It ships proper abi3 wheels, but it downloads the grammar's shared library on first use into a user cache — a fresh install needs network access once. That is weaker than the pinned wheels every other grammar here gets, and I would rather say so than have you find it. If it is a blocker the honest fix is a real tree-sitter-apex wheel, and I have asked upstream for Python bindings.

Three things the generic walk cannot know are handled in the extractor rather than by widening the Java-gated branches in the engine: extends/implements, the SObject a trigger fires on and the one behind a SOQL FROM, and DML statements. new Other() too, since its callee sits in the type field rather than name.

Two filters, both chosen from what real code does rather than guessed. new List<Account>() appears in nearly every method and built a List node collecting 30 edges. And rows.add('x') bound to whichever class happens to declare add() — 34 of 41 edges onto one class were list appends from elsewhere. Note the asymmetry: a name declared by many classes is already safe, because the resolver refuses to bind an ambiguous name. That is why execute is deliberately not filtered — every invocable class declares it, so it only ever resolves within a file.

One behaviour change to call out: the AST path does not reproduce 11 things the regex path emitted. I classified every one — six were new X(...) read as a method declaration, four were the word "Merge" in a comment, one was Database truncated from Database.Batchable. All regex false positives, none a real loss. No existing test needed changing.

Tests: 14 in tests/test_apex_calls.py, checked by mutation — ten plausible wrong implementations, each caught by at least one test. Negative controls: a commented-out call, a local variable named after a method, DML in prose, a collection constructor, and a collection method that must not bind across files.

Shared core is touched in two small places: one optional LanguageConfig field for grammars only reachable through the pack, and the loader branch that uses it. No existing language behaves differently.

Independent of #3106 and #3117.

The Apex extractor never produced calls edges, and its docstring said why: no
tree-sitter grammar on PyPI. That is out of date — aheber/tree-sitter-sfapex
exists, is MIT, and uses the same node type and field names as
tree-sitter-java for declarations and calls, so the shared engine walk drives
it from a config. On my-org-butler, 55 Apex files: 592 calls edges where there
were 0, across 225 distinct targets.

Not the Java grammar: parsing those same files with tree-sitter-java yields
740 ERROR nodes in 53 of 55, silently. The sfapex grammar parses all 55 clean.

The regex extractor stays as the fallback, like Pascal's, so Apex still
extracts without the optional extra — minus calls.

extends/implements, the SObject behind a trigger or a SOQL FROM, DML, and
`new X()` are handled in the Apex extractor rather than by widening the
Java-gated branches in the engine. Two filters keep collection built-ins from
becoming god-nodes: `new List<>()` is not a dependency, and `rows.add(x)` must
not bind to whichever class happens to declare add().

Shared core gains one optional LanguageConfig field for grammars only
reachable through tree-sitter-language-pack, plus the loader branch using it.

@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 4 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds a tree-sitter path to the Apex extractor that runs when the sfapex/language-pack grammar is installed, falling back to the existing regex extractor (now _extract_apex_regex) unchanged when the grammar is missing or a file fails to parse, at lower fidelity and with no calls edges. The AST path drives declarations and calls through the shared generic engine using an _APEX_CONFIG shaped like Java, then _add_apex_specifics layers on Apex-only constructs — extends/implements, the SObject a trigger fires on, SOQL FROM/SOSL RETURNING type usage, DML, new Type() construction, and entry-point annotations. Filters _APEX_BUILTIN_METHODS and constructors from raw_calls so common collection/JSON/Http calls don't collapse into god-nodes or bind cross-file to a single class that happens to declare the same bare name.

Worth a look

  • Apex parse-error roots do not trigger regex fallbackgraphify/extractors/apex.py:59 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Tree-sitter parse errors are treated as successful AST extractiongraphify/extractors/apex.py:61 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Apex REST method annotations are not treated as external entry pointsgraphify/extractors/apex.py:286 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Language-pack loader errors now escape _extract_genericgraphify/extractors/engine.py:2885 · 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 — 1720 functions depend on the 266 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 501 callers, 42 callees
  • new: _rebuild_code() — 98 callers, 50 callees
  • new: _extract_generic() — 20 callers, 24 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_js() — 83 callers, 3 callees
  • new: dispatch_command() — 2 callers, 122 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • …and 25 more — each is listed as a finding

Verification — 1720 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: 1070 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify extract\_apex.

The verifier did not have enough to check extract\_apex, 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 \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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

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

return result


def _extract_apex_regex(path: Path) -> dict:

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_extract_apex_regex()

high coupling complexity (Ca·Ce = 12).

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

return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")


def _add_apex_specifics(path: Path, root, source: bytes, result: dict) -> 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_add_apex_specifics()

fans out to 8 callees (efferent coupling).

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

… points

tree-sitter is error-tolerant: a file it cannot handle comes back as a tree
containing ERROR nodes rather than an exception, so the AST path returned a
confidently wrong result instead of letting the regex path degrade
predictably. Check root.has_error and fall back. Triggers on 0 of the 55 Apex
files in the reference corpus, so it costs nothing on valid code.

Apex REST verbs (@httpget and friends, plus @remoteaction) join @AuraEnabled
and @InvocableMethod as entry points. Without them a @RestResource class looks
dead, because its caller is an HTTP client rather than anything in the corpus.

Both reported by the review bot on Graphify-Labs#3122.

@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.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds a tree-sitter path to the Apex extractor that drives the shared engine walk via an Apex LanguageConfig and falls back to the existing regex extractor when the apex grammar is missing or the file fails to parse (including when tree-sitter returns a tree containing ERROR nodes). Layers Apex-only extraction on top of the engine result in _add_apex_specificsextends/implements edges, the SObject a trigger fires on, SObjects behind SOQL FROM/SOSL RETURNING, DML statements, and entry-point annotations — reusing the engine's node-id scheme so both halves land on the same nodes. Filters out collection/primitive constructors and common built-in method names (e.g. add, send, serialize) from raw_calls so the cross-file resolver doesn't invent dependencies by binding bare method names to a single class that happens to declare them.

Worth a look

  • Builtin-method filter drops legitimate same-file resolved callsgraphify/extractors/apex.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • raw_calls builtin filter drops legitimate same-file calls to methods named add/getgraphify/extractors/apex.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Unterminated test file prevents pytest collectiontests/test_apex_calls.py:331 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • DML statements never produce edges in AST pathgraphify/extractors/apex.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • language pack path bypasses TypeError guard for grammar load failuresgraphify/extractors/engine.py:2884 · 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 — 1726 functions depend on the 272 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 501 callers, 42 callees
  • new: _rebuild_code() — 98 callers, 50 callees
  • new: _extract_generic() — 20 callers, 24 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_js() — 83 callers, 3 callees
  • new: dispatch_command() — 2 callers, 122 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: extract_julia() — 17 callers, 7 callees
  • …and 25 more — each is listed as a finding

Verification — 1726 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: 1076 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify extract\_apex.

The verifier did not have enough to check extract\_apex, 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 \_extract\_generic.

The verifier did not have enough to check \_extract\_generic, 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

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

return result


def _extract_apex_regex(path: Path) -> dict:

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_extract_apex_regex()

high coupling complexity (Ca·Ce = 12).

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

return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")


def _add_apex_specifics(path: Path, root, source: bytes, result: dict) -> 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_add_apex_specifics()

fans out to 8 callees (efferent coupling).

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

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