Skip to content

fix(pipeline): a bare Go reference never binds a struct Field - #1944

Merged
DeusData merged 5 commits into
DeusData:mainfrom
ilyabrykau-orca:fix/go-bare-ref-field-binding
Aug 31, 2026
Merged

fix(pipeline): a bare Go reference never binds a struct Field#1944
DeusData merged 5 commits into
DeusData:mainfrom
ilyabrykau-orca:fix/go-bare-ref-field-binding

Conversation

@ilyabrykau-orca

Copy link
Copy Markdown
Contributor

Fixes #1942. Stacked on #1937#1940 — the reproduce-first probe needs Go Field nodes to exist (#1940) before a bare local can falsely bind one, and #1940 needs this before its node recovery is net-positive for reference edges. The tip commit is this PR's change; suggested merge order #1937#1940 → this (or the last two together).

What

The READS/WRITES resolvers and the USAGE registry fallback hand bare reference text to the short-name registry, which contains Field nodes — so once Go struct fields exist, every Go local err := … binds whichever struct field is named err, project-wide:

onto Go Field nodes (stack without this fix) count
USAGE 21308 (top target: a test struct's field T, 3013 edges)
WRITES 5191

In Go the binding is impossible by construction: a field is only reachable through a selector expression (x.f), and selector references resolve on the LSP path — 100% of the Field-targeted USAGE edges in the census carried dot-less reference text, so there is no legitimate population mixed in.

How

cbm_go_suppress_bare_field_ref(is_go, ref_name, target_label) — pure predicate next to #1937's, consulted at the same four sites (both READS/WRITES resolvers, both USAGE registry fallbacks): drop when the file is Go, the target label is Field, and the reference has no .. Go-gated: C#/Java/C++/Python method bodies legitimately reference their own members bare — cp_reads_writes_cs_static_field pins that shape as required, and it stays untouched.

Field-validated (fresh index, ~1150-file Go+C repo, full stack)

USAGE onto Go fields 21308 → 0, WRITES 5191 → 0. The only remaining field-targeted reference class is 2466 CALLS, which is #1906/#1907's selector-guard territory. Combined stack ends below main's reference totals (USAGE 29198 → 21035, WRITES 2681 → 774) while adding the 4533 field nodes.

Tests

  • Reproduce-first pipeline_go_bare_ref_never_binds_field + _parallel twin (both resolvers, per fix(pipeline): guard USAGE/WRITES/READS against cross-language binds #1937's lesson): RED on the fix(extract): descend into Go struct field_declaration_list #1940 stack (ASSERT(!(cross_file_edge_exists(s, project, "Run", "err", "WRITES"))) failed on both paths), GREEN with the predicate. Fixture asserts the field node exists, so the probe can't pass vacuously.
  • Unit test go_bare_ref_never_binds_field: selector-shaped refs may bind, non-Field targets untouched, non-Go languages untouched, degenerate inputs safe.
  • Full scripts/test.sh venue leg (ASan+UBSan, all suites + contract steps): green on the stack. git clang-format --diff: clean.

#1932 tracks the family. Same root class as #1927: a name-shape heuristic binding without checking what the name can denote.

USAGE, WRITES and READS edges resolve through the same short-name
registry as CALLS but never consulted the DeusData#725 cross-language guard.
On a Go tree with eBPF C probes every Go identifier spelled like a C
one produced a reference edge into the C file: 31.5% of all WRITES on
the originally measured repo crossed the Go->C boundary, led by dozens
of Go test locals named event writing a C probe's automatic variable.

Add cbm_suppress_cross_language_ref() - the reference-edge analog of
cbm_suppress_cross_language_suffix_match - and consult it on BOTH
resolvers of each edge type: the sequential pass (pass_usages.c
resolve_usage_edges registry-fallback branch, resolve_rw_edges) and
their parallel twins (pass_parallel.c resolve_file_usages,
resolve_file_rw). The sequential-only version of this change left 344
Go->C WRITES alive on a ~1150-file repo because large repos resolve
through pass_parallel.c - the field census caught it, and the
parallel-twin test now pins it.

Unlike the CALLS guard the predicate takes no strategy parameter: a
reference edge carries no import-closure evidence, so every registry
strategy is a bare-name guess across a boundary. LSP-backed semantic
references resolve before the fallback and are unaffected. JS/TS stay
one family, and C/C++ count as one family too (.h maps to
CBM_LANG_CPP, so a .c file referencing its own header is not a
boundary).

Field-validated on the ~1150-file Go+C repo: Go->C/C++ WRITES 835->0,
USAGE 1545->0; C->Go 15/90->0; 6432 reference edges dropped in total,
every one cross-language (Go->.json 3034, Go->.hpp/.h 1612, Go->.c
746, Go->.sh/.yaml/.yml 443, ...) and none same-language. CALLS and
IMPORTS totals are byte-identical to main.

Fixes DeusData#1928

Signed-off-by: Ilya Brykau <ilya.brykau@orca.security>
Signed-off-by: Ilya Brykau <ilya.brykau@orca.security>
Signed-off-by: Ilya Brykau <ilya.brykau@orca.security>
Reproduce-first probe for the two fixes on this branch: RED without the
descend fix (count_defs_with_label(r, "Field") == 0, expected 3), GREEN
with it. Asserts the three named fields with their declared types in
return_type, the absence of the blank identifier, and that interface
members keep extracting exactly as before.

Signed-off-by: Ilya Brykau <ilya.brykau@orca.security>
@github-actions

Copy link
Copy Markdown

Thanks for opening this — it has been seen, and it is queued.

This note is automated, but it is not a brush-off: it exists so you know where your PR stands instead of having to guess from silence.

Current review status: working through a backlog. 0.9.1-rc.1 is out, so the release freeze that held reviews is over — but it left a large queue of open pull requests behind it, and we are reading through them oldest-first. The background is in discussion #1144.

What that means for this PR, concretely:

  • It will not be closed for inactivity. No stale bot touches pull requests here.
  • It may still sit a while before a human reads it. That is on us, not on you.
  • Older PRs are read first, so a recent one is not being skipped — it is behind a queue.

Things that will genuinely speed it up whenever review does happen:

  • Keep it rebased on main — the tree is moving quickly right now, and a conflicting branch cannot be reviewed as the diff you intended.
  • Get CI green, or say which failures you believe are pre-existing.
  • Keep the change to one claim. Bundled features and refactors get split before they get merged, which costs you a round trip.
  • Every commit needs a sign-off (git commit -s) — CI enforces DCO.

If this fixes a bug, a reproduction we can run is worth more than a description of the symptom.

Thanks for contributing, and sorry in advance for the wait.

@ilyabrykau-orca

Copy link
Copy Markdown
Contributor Author

CI retrigger (no-op amend, identical tree — no rerun rights): test-windows-guards died in its own harness setup — SETUP FAIL: ASCII baseline did not index … 'nodes': None on the guard's 2-file TypeScript fixture, before any guard ran. Same environmental signature #1907's first run hit and its retriggered run passed with identical code; this PR's diff is Go-gated end to end. No semantic changes.

@ilyabrykau-orca

Copy link
Copy Markdown
Contributor Author

CI retrigger (no-op amend): test-windows-guards again, identical SETUP FAIL: ASCII baseline did not index … nodes: None harness-setup signature — second consecutive hit on this PR, tracked in #1952. No semantic changes.

@ilyabrykau-orca
ilyabrykau-orca force-pushed the fix/go-bare-ref-field-binding branch from c175ca1 to 7297139 Compare August 31, 2026 08:41
The READS/WRITES resolvers and the USAGE registry fallback hand bare
reference text to the short-name registry, which contains Field nodes -
so once Go struct fields exist (DeusData#1935), every Go local err := ... binds
whichever struct field is named err, project-wide: 21308 USAGE and 5191
WRITES onto Go fields on the measured repo, top target a test struct's
field T collecting 3013 edges. In Go that binding is impossible by
construction: a field is only reachable through a selector expression
(x.f), and selector references resolve on the LSP path - every
Field-targeted reference edge in the census carried dot-less text.

Add cbm_go_suppress_bare_field_ref() next to the DeusData#1928 predicate and
consult it at the same four sites (both READS/WRITES resolvers, both
USAGE registry fallbacks): drop the bind when the file is Go, the
target label is Field, and the reference text has no '.'. Go-gated
because C#/Java/C++/Python method bodies legitimately reference their
own members bare (cp_reads_writes_cs_static_field pins that shape).

Field-validated on the DeusData#1940 stack: USAGE onto Go fields 21308 -> 0,
WRITES 5191 -> 0; the only remaining field-targeted edges are 2466
CALLS, which are DeusData#1906/DeusData#1907's selector-guard territory. Reproduce-
first pipeline probes (sequential + parallel twins) were RED on the
stack without this commit.

Fixes DeusData#1942

Signed-off-by: Ilya Brykau <ilya.brykau@orca.security>
@ilyabrykau-orca

Copy link
Copy Markdown
Contributor Author

Fresh-day CI retrigger (no-op amend): third consecutive test-windows-guards harness-setup failure with the identical SETUP FAIL … nodes: None signature (#1952 has the matrix — this PR is now its most-hit entry). Yesterday's runner pool appears to have been the common factor; if this run hits it again I'll leave the job red and defer to maintainers on #1952 rather than keep force-pushing.

@ilyabrykau-orca
ilyabrykau-orca force-pushed the fix/go-bare-ref-field-binding branch from 7297139 to cc00a02 Compare August 31, 2026 09:09
@DeusData

Copy link
Copy Markdown
Owner

#1937 and #1940 are mergedfcd8bfc1 and 61de19bb. This one is queued behind its last check.

First, the thing you should not have had to do: stop force-pushing to chase test-windows-guards. You were right on all three counts. The failure is SETUP FAIL: ASCII baseline did not index … 'nodes': None in the guard's own harness, on a two-file TypeScript fixture, before any guard runs — and your diff is Go-gated end to end, so it cannot be reaching that code. #1952 is ours to fix, not something to out-wait. Three no-op amends is three more than you owed us, and you were explicit each time about what you were doing and why, which is the right way to handle it.

On the review itself — this is unusually careful work, and the specific things that made it reviewable are worth naming, because they are the things that usually go missing:

  • Both resolvers. The sequential and parallel twins, not one. parallel has bitten a merged change in this repo before precisely because it was not in the "obviously related" set.
  • The probe cannot pass vacuously. The fixture asserts the Field node exists before asserting nothing binds it. Without that, the test would have gone green if the change had simply deleted the nodes.
  • Revert-checked. RED with the fix reverted, on both paths, quoted with the failing assertion — not "tests pass".
  • Go-gated, and you said why. cp_reads_writes_cs_static_field pins the C# shape that must keep working. Call-resolution suppressors staying per-language is a standing rule here, arrived at after a global one measured 32.5 edges destroyed per suspicious edge removed. You landed on the right side of it without being told.
  • Honest about what is left. Embedded fields staying unrepresented, and the remaining 2466 CALLS being Go: weak short-name strategies fabricate CALLS edges for selector calls (stdlib receivers hijack project symbols) #1906/fix(pipeline): suppress weak short-name matches for Go selector calls #1907 territory rather than folded in silently.

I verified the load-bearing claim in #1937 by reading the predicate rather than trusting the description: it fails open on unknown caller language, missing or empty path, unknown target language, same language, the JS/TS family, and the C/C++ family. It only drops when both languages are known and differ across families. That is the property that makes it a correctness fix rather than a recall trade, and it is why it could merge without a measurement argument.

The stack order you specified was also correct and load-bearing, so it was followed exactly: #1937 first, then #1940. Landing the ~4600 Field nodes without the reference guard underneath would have minted the collision edges your own measurement predicted.

Thank you — this is a strong first contribution to the project.

@DeusData

Copy link
Copy Markdown
Owner

Post-merge correction, because a review pass after the merge found something I should have caught before it: the strchr(ref_name, '.') == NULL test in cbm_go_suppress_bare_field_ref is inert for Go. Filed as #1962.

Both call paths strip the receiver before the predicate ever sees the name:

  • resolve_lhs_write_name documents its own contract — obj.Field = ... writes the trailing name, so t.err = x yields err, never t.err.
  • is_reference_node never treats selector_expression as a reference kind; the reference is the inner field_identifier, whose span is bare.

So the guard is a blanket veto on Field-targeted references rather than the precise one it documents. Two consequences worth stating plainly, since they bear on evidence I accepted at merge time:

  1. The unit test's ASSERT_FALSE(..., "t.err", "Field") pins an input Go's extractor cannot produce — a dead branch, not a production guarantee.
  2. The "100% of Field-targeted USAGE edges carried dot-less reference text" census is a tautology. The extractor guarantees it. I quoted that figure back to you approvingly when I merged; it is not evidence of what it appeared to show, and that is on me for not checking the extractor before believing the number.

None of this is a regressionmain had no Go Field nodes at all before #1940, so nothing that previously worked is lost. The honest description of the landed stack is "~4588 recovered nodes that reference edges cannot currently reach", not "edges removed". The work stands; the reasoning attached to one third of it does not.

The signal the guard actually wants — field_identifier versus a bare identifier — is computed at extraction time and discarded before the resolver runs, so a precise version is reachable rather than hypothetical. #1962 has the detail and a cheap falsification (index a Go repo, count Field-targeted USAGE/READS/WRITES; the prediction is zero).

No action needed from you unless you want to take #1962 — and no criticism intended in any of it. You disclosed the collision class this stack creates before anyone asked, which is why the follow-up was findable at all.

@ilyabrykau-orca

Copy link
Copy Markdown
Contributor Author

Thank you for the merge review — and especially for the post-merge correction. You are right on both counts: the extractor strips the receiver on every path that reaches the resolver, so the dot test cannot fire, and the census I presented was measuring the extractor's contract back at itself. I should have traced the ref_name provenance before claiming the 100% figure as evidence.

Taking #1962: the plan is exactly the signal you point at — record is-member-access at extraction (the field_identifier half of a selector) on CBMUsage/CBMReadWrite, and let the guard consume that instead of string shape, so genuine t.err = x / cfg.Timeout references bind Field nodes while bare locals stay suppressed. Reproduce-first test + a field census on a real Go repo (predicted: Field-targeted USAGE/WRITES go from the current 0 to the genuine selector population) will be in the PR.

DeusData pushed a commit that referenced this pull request Sep 2, 2026
… not reference text

cbm_go_suppress_bare_field_ref dropped a Field-targeted reference when
strchr(ref_name, '.') == NULL — but the extractor strips the receiver on
every path that reaches the resolver (resolve_lhs_write_name records the
trailing field name of a selector LHS; is_reference_node records the
inner field_identifier), so the dot test could never be false for Go.
The guard was a blanket veto: all ~4588 Go Field nodes recovered by
one 61de19b were unreachable by USAGE/READS/WRITES, including genuine
selector references (issue #1962, found in maintainer post-merge review
of #1944).

The selector-vs-bare distinction exists at extraction time and was
discarded; record it and let the resolver consume it:

- CBMUsage/CBMReadWrite gain is_member_access. The usage recorder sets
  it when the reference node is a field_identifier (the member half of a
  selector); resolve_lhs_write_name reports it through an out-param when
  it takes the field/member LHS branch.
- cbm_go_suppress_bare_field_ref(is_go, is_member_access, target_label)
  refuses a Field bind only for references that were never the member
  half of a selector. All four resolver sites (sequential and parallel,
  USAGE and READS/WRITES) pass the recorded flag.

Reproduce-first: with the veto restored, the extended #1942 fixtures go
RED on both resolver paths at the new asserts (t.err = nil must WRITE
the field; t.n must produce USAGE); with the fix both twins are GREEN
and the original bare-local negatives still hold. The dead
ASSERT_FALSE(..., "t.err", ...) unit case — an input Go's extractor
cannot produce — is replaced by flag-based cases.

Fixes #1962.

Signed-off-by: Ilya Brykau <ilya.brykau@orca.security>
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.

fix(pipeline): a bare Go reference can bind a struct Field — READS/WRITES/USAGE attach every local err to whichever Field is named err

2 participants