Skip to content

fix(swift): parse an if let binding whose await operand is not a call - #3542

Open
L4XB wants to merge 2 commits into
Graphify-Labs:v8from
L4XB:fix/3540-swift-await-optional-binding
Open

L4XB wants to merge 2 commits into
Graphify-Labs:v8from
L4XB:fix/3540-swift-await-optional-binding

Conversation

@L4XB

@L4XB L4XB commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #3540

The gap, mapped

tree-sitter-swift 0.7.3 is the only published release — PyPI has 0.0.1, 0.7.2, 0.7.3 and nothing else — so there is no version to bump to. Measured against the grammar directly:

binding parses
if let r = await fetchRings()
if let r = try await mint()
if let r = await pending
if let r = await box.rings
if var r = await pending
while let r = await pending
guard let r = await pending else {…}

All valid Swift 6. guard parsing places the gap in the if/while binding rule rather than in await.

The repair

await is blanked with spaces, not deleted. That is the whole trick: the repaired source is byte-for-byte the same length, so every offset, line and column downstream still addresses what it did. await names no symbol, so the graph built from the repaired bytes is the graph of the original file — and the call inside try await mint() is still mint().

Gated three ways:

  • Swift only, on config.ts_module.
  • Only when the original parse already errored. A file that parses is never rewritten.
  • The repaired tree is kept only when it buys something — no error at all, or a first error that moved further down the file, since a source can carry this grammar gap and a real mistake at once.

A match sitting behind a // on its line is skipped, so a commented-out binding keeps its text. A block comment or a string literal holding this exact shape is still rewritten — harmless at equal length, and only on a file that already failed to parse. Stated in the code rather than left to be discovered.

What I verified, and what I could not

Verified. Every failing shape above now parses; the file is no longer reported as partially extracted; the parse_errors key is gone. Four mutants, each caught by the test written for it:

mutant
the repair is never applied ✅ caught
await deleted instead of blanked (offsets shift) ✅ caught
the // guard dropped ✅ caught
the rule also sweeps in guard let ✅ caught

That first mutant initially passed, and it was my test's fault: I asserted assertNotIn("parse_error", result) and the key the extractor sets is parse_errors, plural. The singular spelling is never present, so the assertion passed for any file at all. Corrected, and worth naming since it is the same shape of defect this change is about.

Could not reproduce. I built five fixtures aimed at the symbol loss the report measured — a same-named member binding, a binding inside a closure, two failing bindings in one type, declarations nested after the binding, calls inside the if body — and in every one the node and edge counts were identical with and without the fix. tree-sitter's recovery kept the following declarations each time. So I am not repeating the 53→15 numbers as if I had confirmed them; on your corpus the loss is real and measured, on my constructed files the only observable difference is the parse error and its warning.

That is still worth fixing on its own terms: the warning is a true signal being spent on a file the extractor could read perfectly well, and every rule downstream is walking an ERROR subtree for no reason.

Suites

  • tests/test_swift_await_optional_binding.py (new): 4 tests, 14 subtests.
  • -k swift: 68 passed, 6 skipped.
  • Full suite, clean tree vs this branch: 14 failures both ways, identical sets — terraform grammar, ollama SDK and wheel-build tests that fail in my environment before I touch anything.

Changelog entry added under 0.9.61 (unreleased).

tree-sitter-swift 0.7.3 — the only published release, so there is no
version to bump to — accepts `await` in an `if let` binding only when the
operand is a direct call. `if let r = await pending`, `if let r = await
box.rings` and `if let r = try await mint()` are all valid Swift 6 and all
parse as ERROR, which marks the file partially extracted and leaves every
rule downstream walking a damaged subtree. `guard let` with the same
operand parses, which places the gap in the if/while binding rule rather
than in `await`.

The repair blanks the `await` with SPACES rather than deleting it. That is
the whole trick: the repaired source is byte-for-byte the same length, so
every offset, line and column downstream still addresses what it did.
`await` names no symbol, so the graph built from the repaired bytes is the
graph of the original file.

It is gated three ways. Swift only; only when the original parse already
errored, so a file that parses is never rewritten; and the repaired tree is
kept only when it buys something — no error at all, or a first error that
moved further down, since a file can carry this grammar gap and a real
mistake at once. A match sitting behind a `//` is skipped, so a
commented-out binding keeps its text.

Four mutants, each caught: the repair never applied, `await` deleted
instead of blanked, the comment guard dropped, and `guard let` swept in.

Fixes Graphify-Labs#3540

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

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


Graphify review — findings

Fixes Swift extraction aborting on if let/while let/if var bindings whose await operand isn't a direct call — a tree-sitter-swift grammar gap that reported valid Swift 6 files as partially extracted. When a Swift file parses with errors, _swift_blank_await_bindings overwrites the await/try await keyword with equal-length spaces so every downstream byte offset, line and column stays put, and reparses; the repair is kept only if it clears the error or pushes the first error further down, and commented-out (//) bindings are left untouched.

Worth a look

  • Swift await repair skips real code when // appears earlier in a string on the same linegraphify/extractors/engine.py:3173 · 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 — 1127 functions depend on the 435 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 27 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_corpus_parallel() — 26 callers, 11 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: _extract_with_adaptive_retry() — 22 callers, 10 callees
  • …and 13 more — each is listed as a finding

Verification — 1127 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: 822 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 — full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_agents_platform.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 — impact, 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_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_build.py — impact, full-run-safety
  • tests/test_build_merge_dedup_scope.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_cache.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — impact, full-run-safety
  • tests/test_chunking.py — impact, full-run-safety
  • tests/test_cjs_module_extension.py — impact, full-run-safety
  • tests/test_claude_cli_backend.py — impact, full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_codebuddy.py — full-run-safety
  • tests/test_community_hub_labels.py — full-run-safety
  • tests/test_community_labels_skill.py — full-run-safety
  • tests/test_confidence.py — full-run-safety
  • tests/test_corrupt_graph_json.py — full-run-safety
  • tests/test_cpp_nested_and_cli.py — impact, full-run-safety
  • tests/test_cpp_objc_cross_file_calls.py — full-run-safety
  • tests/test_cpp_preprocess.py — full-run-safety
  • tests/test_cross_extension_reexport_self_cycle.py — full-run-safety
  • tests/test_cross_language_call_resolution.py — full-run-safety
  • tests/test_cross_repo_member_calls.py — full-run-safety
  • tests/test_cross_repo_shared_types.py — full-run-safety
  • tests/test_csharp_call_site_generic_args.py — full-run-safety
  • tests/test_csharp_enum_members.py — full-run-safety
  • … and 226 more

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

changed code file(s) with no mapped test (CHANGELOG.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

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

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

return _SWIFT_AWAIT_BINDING.sub(replace, source)


def _extract_generic(

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

fans out to 27 callees (efferent coupling); 18 callers depend on it (afferent coupling).

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

Graphify review on Graphify-Labs#3542, reproduced before it was fixed. The repair skipped a
match when any `//` appeared earlier on the line, which is not the same
question as "is this line commented out":

    let base = "https://api.example.com"; if let x = await f(base) { }

The `//` belongs to the URL, the binding is real code, and the skip left the
file unparseable, which is the exact defect this function exists to clear. A
URL in Swift source is ordinary, so this was not a corner.

`_swift_line_comment_start` scans the line tracking string state and returns
the offset of the `//` that actually opens a comment. What it does not model is
stated in its docstring rather than left implicit: raw strings, multi-line
literals and interpolation. Getting one of those wrong costs nothing either
way, since reading a comment as code rewrites an `await` inside a comment at
equal length on a file that already failed to parse, and reading code as a
comment is the behaviour being replaced.

Two cells: three shapes with a `//` inside a string are repaired and keep their
length, and a real line comment is still left alone in both positions it can
open. Five mutations, four killed; the fifth turns `<` into `<=`, which is
equivalent because the pattern starts at `if`/`while` and can never begin on
the `/` the offset points at.
@L4XB
L4XB force-pushed the fix/3540-swift-await-optional-binding branch from 5279932 to 0ba46e2 Compare September 15, 2026 13:03
@L4XB

L4XB commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

The advisory holds up, and it is fixed at 0ba46e2. I reproduced it before changing anything:

input : let base = "https://api.example.com"; if let x = await fetch(base) { }
output: let base = "https://api.example.com"; if let x = await fetch(base) { }
repaired? False

The // belongs to the URL, the binding is real code, and the skip left the file unparseable, which is the exact defect this function exists to clear. So "is there a // earlier on the line" was the wrong question.

_swift_line_comment_start now scans the line tracking string state and returns the offset of the // that actually opens a comment. What it does not model is in its docstring rather than left implicit: raw strings (#"..."#), multi-line """ literals and interpolation. Getting one of those wrong costs nothing in either direction, because reading a comment as code rewrites an await inside a comment at equal length on a file that already failed to parse, and reading code as a comment is precisely the behaviour being replaced.

Two cells, and a fixture correction worth mentioning: my first draft used guard let r = await pending as one of the repaired shapes, and it failed, because guard is deliberately left alone here since the grammar already accepts it. The existing test_the_repair_leaves_everything_else_alone says so, and my own test caught me contradicting it.

Five mutations, four killed:

killed    the quote-aware scan is gone (any // counts)
killed    a real line comment no longer skips
killed    the string state is never entered
killed    the escape skip is dropped
SURVIVED  the boundary slips (<= instead of <)

The survivor is equivalent rather than a gap: _SWIFT_AWAIT_BINDING starts at \b(?:if|while), so a match can never begin on the / the comment offset points at, and the two can never be equal.

70 passed, 6 skipped across every Swift test. Noting for the record that the finding was marked "agreed by 2 of 2 but NOT verified" and it was right anyway, so the escalation was worth making.

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

Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).

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


Graphify review — findings

Repairs Swift extraction when an if let/while let/var binding's await operand isn't a direct call — shapes like if let r = await pending that tree-sitter-swift 0.7.3 mis-parses as errors and that previously left the rest of the file only partially extracted. On an errored Swift parse, _swift_blank_await_bindings overwrites the await (or try await) keyword with equal-length spaces so every downstream byte offset, line, and column stays put, then re-parses and keeps the result only if it clears the error or pushes the first error further down. Comment-marker detection in _swift_line_comment_start is quote-aware so a // inside a string literal (e.g. a URL) doesn't cause a needed binding to be skipped, while block comments, raw strings, and interpolation are left unmodeled as harmless at equal length.

No blocking issues surfaced. 8 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1133 functions depend on the 441 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _extract_generic() — 18 callers, 27 callees
  • new: extract_files_direct() — 17 callers, 20 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • new: extract_corpus_parallel() — 26 callers, 11 callees
  • new: dispatch_command() — 2 callers, 124 callees
  • new: extract_objc() — 27 callers, 9 callees
  • new: _extract_with_adaptive_retry() — 22 callers, 10 callees
  • …and 13 more — each is listed as a finding

Verification — 1133 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: 828 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 — full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_agents_platform.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 — impact, 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_benchmark.py — full-run-safety
  • tests/test_benchmark_raw_graph.py — full-run-safety
  • tests/test_build.py — impact, full-run-safety
  • tests/test_build_merge_dedup_scope.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_cache.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — impact, full-run-safety
  • tests/test_chunking.py — impact, full-run-safety
  • tests/test_cjs_module_extension.py — impact, full-run-safety
  • tests/test_claude_cli_backend.py — impact, full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_codebuddy.py — full-run-safety
  • tests/test_community_hub_labels.py — full-run-safety
  • tests/test_community_labels_skill.py — full-run-safety
  • tests/test_confidence.py — full-run-safety
  • tests/test_corrupt_graph_json.py — full-run-safety
  • tests/test_cpp_nested_and_cli.py — impact, full-run-safety
  • tests/test_cpp_objc_cross_file_calls.py — full-run-safety
  • tests/test_cpp_preprocess.py — full-run-safety
  • tests/test_cross_extension_reexport_self_cycle.py — full-run-safety
  • tests/test_cross_language_call_resolution.py — full-run-safety
  • tests/test_cross_repo_member_calls.py — full-run-safety
  • tests/test_cross_repo_shared_types.py — full-run-safety
  • tests/test_csharp_call_site_generic_args.py — full-run-safety
  • tests/test_csharp_enum_members.py — full-run-safety
  • … and 226 more

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

changed code file(s) with no mapped test (CHANGELOG.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

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

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

return _SWIFT_AWAIT_BINDING.sub(replace, source)


def _extract_generic(

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

fans out to 27 callees (efferent coupling); 18 callers depend on it (afferent 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.

Swift AST extraction aborts on if let x = await <non-call> and if let x = try await ..., silently dropping most of the file

1 participant