Files
zzet--gortex/internal/resolver/framework_synth.go
Andrey Kumanyaev e53ad3684d Resolve GDScript calls the receiver names, and refuse the ones it doesn't
Four attribution gaps survived receiver-aware call extraction. Measured on
a Godot project of ~490 files, 175 of 1002 targets with callers missed at
least one calling file.

Static methods and autoload callers. Neither was a distinct defect — the
minimal shapes from the report already resolved. What did not:

  - A call outside every func was dropped entirely. `var hp = Stats.roll()`
    and `@onready var ui = Hud.build()` are a script's whole initialisation
    layer, and Godot puts a lot there. Attribute them to the file, the same
    rule Python applies to module-level calls.
  - `self.tick()` in a script with no `class_name` — which every autoload
    entry point is — has no nameable receiver, so the member shape carried
    no evidence and the call settled on the name-only tier find_usages
    suppresses by default. The file declares the callee, so emit the
    free-function shape and let the same-file tier bind it exactly.
  - `const Persist = preload("res://…")` is how a static-utility script
    with no `class_name` is reached, and the alias is file-local: the same
    name one directory over means a different script. Record the loaded
    path on the const and bind through it.

Same-named methods cross-linking. The dangerous one: a call states its
receiver, and when no member of that receiver was found a weak tier still
answered with whatever same-named method sat nearest the caller. With
`apply` in several classes, `Carry.apply`'s caller list held the calls
belonging to `Items` and `Effects` — the count looks reassuring while
pointing at code the change cannot reach. A receiver gate now demotes a
provable mismatch to the speculative tier, mirroring the C# gate. Only a
provable one: inherited dispatch survives by walking the receiver's
declared base chain, and a base that cannot be read as one in-repo class
name, an autoload or alias receiver on its own script, and a receiver the
calling file declares itself all decline to judge.

Signal wiring. Godot passes the method itself — `pressed.connect(_on_x)`,
`Callable(self, "_on_x")`, and the scene's own `[connection … method=…]`
block. None is a call, so rename rewrote the definition and the ordinary
call sites and left the wiring pointing at a name that no longer exists:
the project still parses, nothing errors anywhere, and the button simply
stops working. All three shapes are now usages, so rename rewrites them —
including in the `.tscn`, bound through the target node's script rather
than by name, which is right only while the handler name is unique and
`_on_pressed` never is.

The receiver gate runs whole-graph, so it is gated on the resolve having
seen a GDScript node at all; nothing else it reads can move a verdict.
2026-08-07 23:02:16 +02:00

1628 lines
67 KiB
Go

package resolver
import (
"iter"
"path/filepath"
"strings"
"time"
"github.com/zzet/gortex/internal/graph"
)
// Framework-dispatch synthesizer engine.
//
// Direct AST/LSP resolution lands the calls a compiler can see. A large
// class of real call edges, though, is wired by a *framework* at runtime
// and is invisible to static resolution: a gRPC client stub dispatched
// to its server handler, a Temporal workflow proxy to its activity, an
// event published on one side of an in-process channel and handled on
// the other, a JS bridge method routed to its native implementation.
//
// FrameworkSynthesizer is the plugin contract for a pass that
// materialises one such family of edges. Every synthesizer is a
// full-recompute, idempotent pass: it derives each edge it owns from
// durable graph state (placeholder edges plus their Meta markers, shared
// topic nodes, registration call edges) so a reindex of any endpoint
// re-lands or un-lands the edge without leaving a stale one behind —
// graph.AddEdge dedupes by edge key and graph.EvictFile drops a node's
// edges in both directions. Every edge a synthesizer lands is stamped
// with provenance (StampSynthesized) so its origin is auditable and the
// `analyze kind=synthesizers` roll-up can attribute it.
//
// The engine is the single orchestration point: the indexers call
// RunFrameworkSynthesizers at every settle point (full index, watcher
// reindex, incremental reindex) in place of invoking each pass directly,
// so adding a synthesizer (a native-bridge resolver, an event-channel
// pass) is one line in defaultFrameworkSynthesizers rather than an edit
// at six call sites.
type FrameworkSynthesizer interface {
// Name is the stable provenance tag stamped on every edge the
// synthesizer lands (lower-kebab, e.g. "grpc-stub", "event-channel").
Name() string
// Synthesize runs the pass over g and returns the number of edges the
// synthesizer owns (landed on a real target) after this run.
Synthesize(g graph.Store) int
}
// Edge.Meta keys stamped by StampSynthesized.
const (
// MetaSynthesizedBy names the synthesizer that produced an edge.
MetaSynthesizedBy = "synthesized_by"
// MetaProvenance records that an edge is a heuristic materialisation
// rather than a compiler-verified fact.
MetaProvenance = "provenance"
// ProvenanceHeuristic is the MetaProvenance value the string- and
// name-keyed framework synthesizers stamp — these edges are
// framework-dispatch inferences correlated by a literal (an event
// name, a dispatch string, a registry key) with no type evidence.
ProvenanceHeuristic = "heuristic"
// ProvenanceFramework is the MetaProvenance value the typed,
// decorator-, base-list- or type-keyed synthesizers stamp — the
// framework's own contract (a decorator, a generic base, a typed
// listener parameter) names the target, so the edge carries more
// confidence than a string-correlated guess. analyze kind=synthesizers
// reports the two tiers separately from the same MetaProvenance read.
ProvenanceFramework = "framework"
)
// Confidence tiers the framework synthesizers stamp on a landed edge.
// Typed/decorator/base-list/type-keyed passes (RTK Query, Celery, Spring,
// MediatR, Sidekiq, Laravel, GoFrame) use ConfidenceTyped; the string-
// and name-keyed passes (Vuex, Redux-thunk, object-registry, fn-pointer,
// Django) use ConfidenceHeuristic.
const (
// ConfidenceTyped is the confidence for a type-/decorator-/base-list-
// keyed dispatch edge — the framework contract names the target.
ConfidenceTyped = 0.85
// ConfidenceHeuristic is the confidence for a string-/name-keyed
// dispatch edge — correlated by a literal, not by a type.
ConfidenceHeuristic = 0.6
)
// Stable per-synthesizer provenance names. Used both as the registry
// label (for the report grouping) and as the value stamped on each
// landed edge, so the two never drift.
const (
SynthGRPCStub = "grpc-stub"
SynthTemporalStub = "temporal-stub"
SynthEventChannel = "event-channel"
SynthSwiftObjC = "swift-objc-bridge"
SynthReactNative = "react-native-bridge"
SynthReactNativePair = "react-native-native-pair"
SynthObserverChannel = "observer-channel"
SynthClosureCollection = "closure-collection"
SynthReactSetState = "react-setstate"
SynthFlutterSetState = "flutter-setstate"
SynthKMPExpectActual = "kmp-expect-actual"
SynthExpoModules = "expo-modules-bridge"
SynthFabric = "fabric-codegen"
SynthMyBatis = "mybatis"
SynthRustScope = "rust-scope"
SynthFactoryChain = "factory-chain"
SynthSQLCallsite = "sql-callsite"
SynthStoreFactory = "store-factory"
SynthReduxThunk = "redux-thunk"
SynthNgRxEffect = "ngrx-effect"
SynthObjectRegistry = "object-registry"
SynthRTKQuery = "rtk-query"
SynthVuexDispatch = "vuex-dispatch"
SynthCelery = "celery-dispatch"
SynthSpringEvent = "spring-event"
SynthMediatR = "mediatr-dispatch"
SynthCSharpIfaceDispatch = "csharp-iface-dispatch"
SynthCSharpDIIfaces = "csharp-di-implemented-interfaces"
SynthSidekiq = "sidekiq-dispatch"
SynthLaravelEvent = "laravel-event"
SynthFnPointerDispatch = "fn-pointer-dispatch"
SynthMacroExpansion = "macro-expansion"
SynthGoFrameRoute = "goframe-route"
SynthDjangoDescriptor = "django-descriptor"
SynthExpressResolve = "express-resolve"
SynthReactResolve = "react-resolve"
SynthFastAPIResolve = "fastapi-resolve"
SynthRailsResolve = "rails-resolve"
SynthSwiftUIResolve = "swiftui-resolve"
SynthUIKitResolve = "uikit-resolve"
SynthVaporResolve = "vapor-resolve"
SynthGodotAutoload = "godot-autoload"
SynthGodotPreloadAlias = "godot-preload-alias"
SynthGodotConnection = "godot-connection"
SynthGinMiddleware = "gin-middleware"
SynthSvelteKitLoad = "sveltekit-load"
SynthSpeculative = "speculative-dispatch"
SynthFnValue = SynthFnValueCallback
SynthPascalFormName = SynthPascalForm
SynthValueRefName = SynthValueRef
)
// StampSynthesized marks an edge as the product of a framework
// synthesizer: which synthesizer produced it (name) and that it is a
// heuristic materialisation. Safe on an edge with a nil Meta map.
func StampSynthesized(e *graph.Edge, name string) {
if e == nil {
return
}
if e.Meta == nil {
e.Meta = map[string]any{}
}
e.Meta[MetaSynthesizedBy] = name
if _, ok := e.Meta[MetaProvenance]; !ok {
e.Meta[MetaProvenance] = ProvenanceHeuristic
}
}
// StampSynthesizedTyped marks an edge as the product of a typed-tier
// framework synthesizer: like StampSynthesized, but records
// ProvenanceFramework instead of ProvenanceHeuristic so the
// type-/decorator-/base-list-keyed passes (RTK Query, Celery, Spring,
// MediatR, Sidekiq, Laravel, GoFrame) separate from the string-keyed
// ones in analyze kind=synthesizers. Safe on an edge with a nil Meta map.
func StampSynthesizedTyped(e *graph.Edge, name string) {
if e == nil {
return
}
if e.Meta == nil {
e.Meta = map[string]any{}
}
e.Meta[MetaProvenance] = ProvenanceFramework
StampSynthesized(e, name)
}
// UnstampSynthesized clears the provenance markers an edge picked up from
// a synthesizer. Called when a pass re-orphans an edge (its target
// disappeared) so the edge reads as a plain placeholder again.
func UnstampSynthesized(e *graph.Edge) {
if e == nil || e.Meta == nil {
return
}
delete(e.Meta, MetaSynthesizedBy)
delete(e.Meta, MetaProvenance)
}
// synthFunc adapts a plain pass function into a FrameworkSynthesizer so
// the existing passes (ResolveGRPCStubCalls, …) register without a
// wrapper type each.
//
// scopedFn is optional: passes with bespoke cross-repository reconciliation may
// consume the changed-repo prefix set directly. Every other pass runs through
// frameworkScopedStore on a partial invocation; no synthFunc is permitted to
// fall back to the unfiltered workspace store.
type synthFunc struct {
name string
fn func(graph.Store) int
scopedFn func(graph.Store, map[string]bool) int
// candFn is the shared-stream form: the pass consumes the candidate
// buffers the census dispatcher collected instead of re-decoding whole
// edge kinds. Used only when a full-census run armed the pass's
// collectors; every other invocation keeps fn / scopedFn.
candFn func(graph.Store, *frameworkPassCandidates) int
}
func (s synthFunc) Name() string { return s.name }
func (s synthFunc) Synthesize(g graph.Store) int { return s.fn(g) }
// synthesizeScoped preserves the legacy entry point used by focused tests. A
// non-nil scope never reaches fn with the unfiltered store.
func (s synthFunc) synthesizeScoped(g graph.Store, scope map[string]bool) int {
if scope == nil {
return runLegacyFrameworkSynth(g, s.fn)
}
if s.scopedFn != nil {
return s.scopedFn(g, scope)
}
return runLegacyFrameworkSynth(newFrameworkScopedStore(g, scope, nil), s.fn)
}
// frameworkSynthLanguageFamilies is deliberately conservative. An absent
// entry means that the pass is generic, spans too many runtimes to bound
// safely, or has not yet been audited, and therefore always runs. A mapped
// pass may be skipped only when its candidate domain contains none of the
// listed language families.
var frameworkSynthLanguageFamilies = map[string][]string{
SynthSwiftObjC: {"apple"},
SynthReactNative: {"web", "apple", "jvm"},
SynthReactNativePair: {"apple", "jvm"},
SynthClosureCollection: {"apple"},
SynthKMPExpectActual: {"jvm"},
SynthExpoModules: {"web", "apple", "jvm"},
SynthFabric: {"web", "apple", "jvm"},
SynthMyBatis: {"jvm"},
SynthSQLCallsite: {"sql"},
SynthStoreFactory: {"web"},
SynthReduxThunk: {"web"},
SynthNgRxEffect: {"web"},
SynthObjectRegistry: {"web"},
SynthRTKQuery: {"web"},
SynthVuexDispatch: {"web"},
SynthCelery: {"python"},
SynthSpringEvent: {"jvm"},
SynthMediatR: {"dotnet"},
SynthCSharpIfaceDispatch: {"dotnet"},
SynthCSharpDIIfaces: {"dotnet"},
SynthSidekiq: {"ruby"},
SynthLaravelEvent: {"php"},
SynthFnPointerDispatch: {"c"},
SynthMacroExpansion: {"c"},
SynthGinMiddleware: {"go"},
SynthExpressResolve: {"web"},
SynthReactResolve: {"web"},
SynthFastAPIResolve: {"python"},
SynthRailsResolve: {"ruby"},
SynthSwiftUIResolve: {"apple"},
SynthUIKitResolve: {"apple"},
SynthVaporResolve: {"apple"},
SynthGoFrameRoute: {"go"},
SynthSvelteKitLoad: {"web"},
SynthRustScope: {"rust"},
SynthPascalFormName: {"pascal"},
}
type frameworkCandidateSummary struct {
all map[string]int
scoped map[string]int
allMarkers map[string]int
scopedMarkers map[string]int
// edges is the cold-run EdgeCalls admission census. Valid only when the
// census walked the full stream (nil scope); scoped runs never consult it.
edges frameworkEdgeCensus
// csharpTypeNames counts distinct C# type/interface names, saturating at
// two — the receiver-gate tail can demote only when the receiver and
// target names differ and both are indexed C# types. Full-census runs only.
csharpTypeNames frameworkDistinctNames
// fullCensus records that this summary walked the ENTIRE store — a nil
// scope, or a non-nil scope under the daemon's full-coverage attestation.
// Absence-proof gates (edge census, family/receiver tails) may only
// trust counts from a full walk; a partial summary cannot prove absence.
fullCensus bool
// streams carries the shared-stream candidate buffers the census edge
// walks collected for the converted synthesizers. Full-census runs only;
// nil on a partial run, where every pass keeps its own scoped scans.
streams *frameworkStreamCandidates
}
// frameworkDistinctNames counts distinct non-empty names, saturating at two.
type frameworkDistinctNames struct {
first string
count int
}
func (d *frameworkDistinctNames) note(name string) {
if name == "" || d.count >= 2 {
return
}
if d.count == 0 {
d.first = name
d.count = 1
return
}
if name != d.first {
d.count = 2
}
}
// frameworkEdgeCensus is the shared EdgeCalls admission census for the
// via-gated synthesizers: one Meta-decoding pass over the same edge stream
// those passes read, so each pass's own admission predicate is answered once
// instead of per pass. valid is set only on a nil-scope run — a scoped
// synthesizer view can admit incident placeholder edges a scoped stream walk
// would not see, so a partial census cannot prove absence.
type frameworkEdgeCensus struct {
valid bool
// via holds every Meta["via"] string present on an EdgeCalls edge.
via map[string]bool
// expressHandlerRef: the express_handler_ref Meta key rode on an
// unresolved-target edge.
expressHandlerRef bool
// recvConst: a non-empty Meta["recv_const"] rode on an unresolved-target
// edge.
recvConst bool
// setStateTarget: some edge's To satisfies isSetStateTarget.
setStateTarget bool
// objectRegistryValue: a non-empty Meta["registry_value"] rode on an
// object-registry via edge.
objectRegistryValue bool
// grpcStub mirrors ResolveGRPCStubCalls' EXACT admission predicate: a
// via=="grpc.stub" EdgeCalls edge carrying non-empty grpc_service AND
// grpc_method metadata. Presence of the via alone is not enough — the
// pass discards service/method-less stubs before building its index.
grpcStub bool
// temporalVia: some EdgeCalls via has the "temporal." prefix — the first
// half of ResolveTemporalCalls' presence probe.
temporalVia bool
// temporalAnnotation: some EdgeAnnotated target satisfies the exact Java
// temporal annotation-role predicate — the probe's second half. Filled by
// a break-on-first-hit EdgeAnnotated walk that runs only when no
// temporal via was seen, mirroring the pass's own short-circuit.
temporalAnnotation bool
}
// collectFrameworkEdgeCensus makes the cold-run EdgeCalls pass feeding
// frameworkSynthEdgePreflights. Every flag is a necessary condition copied
// verbatim from the consuming pass's own loop filter, so a missed flag can
// only keep a pass enabled, never skip one that could land an edge.
//
// The same decoded walk doubles as the shared-stream candidate dispatcher:
// when streams is non-nil, every edge is also offered to the armed per-pass
// collectors, and the (smaller) EdgeAnnotated and EdgeReferences kinds are
// walked once here for their consumers instead of once per pass.
func collectFrameworkEdgeCensus(g graph.Store, streams *frameworkStreamCandidates) frameworkEdgeCensus {
census := frameworkEdgeCensus{valid: true, via: map[string]bool{}}
for e := range graph.FrameworkCensusEdgesSeq(g, graph.EdgeCalls) {
streams.collectCalls(e)
if isSetStateTarget(e.To) {
census.setStateTarget = true
}
if e.Via != "" {
census.via[e.Via] = true
if e.Via == objectRegistryVia && !census.objectRegistryValue && e.RegistryValue != "" {
census.objectRegistryValue = true
}
if e.Via == "grpc.stub" && !census.grpcStub && e.GRPCService != "" && e.GRPCMethod != "" {
census.grpcStub = true
}
if !census.temporalVia && strings.HasPrefix(e.Via, "temporal.") {
census.temporalVia = true
}
}
if graph.IsUnresolvedTarget(e.To) {
if e.HasExpressHandlerRef {
census.expressHandlerRef = true
}
if e.RecvConst != "" {
census.recvConst = true
}
}
}
if streams.wantsAnnotated() {
// The temporal pass consumes the matched annotation edges
// themselves, so the presence probe's break-on-first-hit walk
// becomes one full collection walk of this small kind — replacing
// the pass's own EdgeAnnotated scan.
for e := range graph.FrameworkCensusEdgesSeq(g, graph.EdgeAnnotated) {
if role, member := temporalRoleForJavaAnnotation(e.To); role != "" || member != "" {
streams.addAnnotated(e)
}
}
if !census.temporalVia {
census.temporalAnnotation = streams.annotatedCount() > 0
}
} else if !census.temporalVia {
for e := range graph.FrameworkCensusEdgesSeq(g, graph.EdgeAnnotated) {
if role, member := temporalRoleForJavaAnnotation(e.To); role != "" || member != "" {
census.temporalAnnotation = true
break
}
}
}
if streams.wantsRefs() {
for e := range graph.FrameworkCensusEdgesSeq(g, graph.EdgeReferences) {
streams.collectRefs(e)
}
}
return census
}
// summarizeFrameworkCandidates makes one metadata-free node pass for the
// synthesizer run. Full/cold runs retain the whole-graph projection; partial
// runs read only the changed repository buckets and use that same candidate
// census to gate both scoped and legacy synthesizers.
func summarizeFrameworkCandidates(g graph.Store, scope map[string]bool) frameworkCandidateSummary {
return summarizeFrameworkCandidatesForFiles(g, scope, nil)
}
func summarizeFrameworkCandidatesForFiles(
g graph.Store,
scope map[string]bool,
filePaths []string,
) frameworkCandidateSummary {
return summarizeFrameworkCandidatesCensus(g, scope, filePaths, false)
}
// summarizeFrameworkCandidatesCensus is the census-aware form. censusEligible
// carries the daemon's attestation that a non-nil scope covers every tracked
// repository (a cold / full-reconciliation batch): the summary then reads the
// RAW node stream and builds the full edge census — census scope and
// execution scope deliberately diverge, execution stays on the scoped store.
func summarizeFrameworkCandidatesCensus(
g graph.Store,
scope map[string]bool,
filePaths []string,
censusEligible bool,
) frameworkCandidateSummary {
summary := frameworkCandidateSummary{
all: map[string]int{},
scoped: map[string]int{},
allMarkers: map[string]int{},
scopedMarkers: map[string]int{},
}
// fullCensus: the summary may treat the store as fully covered only when
// there is no exact changed-file frontier. A nil repository scope can mean
// the empty-prefix single-repository shape; filePaths must still win.
fullCensus := len(filePaths) == 0 && (scope == nil || censusEligible)
summary.fullCensus = fullCensus
var observerRoles map[string]uint8
observerRolesOverflow := false
var nodes iter.Seq[*graph.Node]
if !fullCensus {
nodes = graph.NodesLightInScopeSeq(g, frameworkScopePrefixes(scope), filePaths)
} else {
nodes = graph.NodesLightSeq(g)
}
for n := range nodes {
if n == nil {
continue
}
family := frameworkLanguageFamily(n.Language)
if fullCensus {
summary.noteColdCSharpTypeName(n)
}
if role := recordFrameworkNodeCandidates(summary.allMarkers, n, family); role != 0 && n.ID != "" {
if observerRoles == nil {
observerRoles = map[string]uint8{}
}
if len(observerRoles) < frameworkScopeRetainedRowCap {
observerRoles[n.ID] = role
} else {
observerRolesOverflow = true
}
}
if !fullCensus {
recordFrameworkNodeCandidates(summary.scopedMarkers, n, family)
}
if family == "" {
continue
}
summary.all[family]++
if !fullCensus {
summary.scoped[family]++
}
}
// Observer synthesis needs registrar and dispatcher methods accessing the
// same field. When both name vocabularies exist, one metadata-free scan of
// accesses_field edges proves whether such a channel can exist. This is
// stricter than two unrelated name hits but retains every candidate the
// synthesizer itself can consume.
if observerRolesOverflow {
// The proof cache is deliberately capped. Overflow cannot prove the
// pass inert, so retain it; the scoped synthesizer view still bounds
// its actual candidate rows.
summary.allMarkers[SynthObserverChannel]++
} else if summary.allMarkers[frameworkMarkerObserverRegistrar] > 0 &&
summary.allMarkers[frameworkMarkerObserverDispatcher] > 0 {
fieldRoles := map[string]uint8{}
visit := func(e *graph.Edge) bool {
if e == nil || e.From == "" || e.To == "" {
return true
}
role := observerRoles[e.From]
if role == 0 {
return true
}
fieldRoles[e.To] |= role
if fieldRoles[e.To] == (frameworkObserverRegistrarRole | frameworkObserverDispatcherRole) {
summary.allMarkers[SynthObserverChannel]++
return false
}
return true
}
if fullCensus {
if _, streaming := g.(graph.LightEdgeSequencer); streaming {
for edge := range graph.EdgesLightSeq(g, graph.EdgeAccessesField) {
if !visit(edge) {
break
}
}
} else if light, ok := g.(graph.LightEdgeScanner); ok {
for _, edge := range light.AllEdgesLight(graph.EdgeAccessesField) {
if !visit(edge) {
break
}
}
} else {
for edge := range g.EdgesByKind(graph.EdgeAccessesField) {
if !visit(edge) {
break
}
}
}
} else {
if _, streaming := g.(graph.ScopedProjectionSequencer); streaming {
for row := range graph.EdgesInScopeSeq(
g, frameworkScopePrefixes(scope), filePaths, graph.EdgeAccessesField,
) {
if !visit(row.Edge) {
break
}
}
} else if light, ok := g.(graph.LightEdgeScanner); ok {
for _, edge := range light.AllEdgesLight(graph.EdgeAccessesField) {
if !visit(edge) {
break
}
}
} else {
for row := range graph.EdgesInScopeSeq(
g, frameworkScopePrefixes(scope), filePaths, graph.EdgeAccessesField,
) {
if !visit(row.Edge) {
break
}
}
}
}
}
if fullCensus {
// Arm the shared-stream collectors with the family / node-marker
// verdicts the light walk just produced (the census-derived edge
// preflights are decided by the walk below and can only narrow
// admission further), then run the census edge walks as the single
// candidate dispatcher for every armed pass.
present, markers := summary.all, summary.allMarkers
if !fullCensus {
present, markers = summary.scoped, summary.scopedMarkers
}
summary.streams = newFrameworkStreamCandidates(g, present, markers)
summary.edges = collectFrameworkEdgeCensus(g, summary.streams)
}
return summary
}
// noteColdCSharpTypeName feeds the receiver-gate tail census: only nodes the
// demote pass itself can index (C# type/interface names) are counted, and
// only up to the two distinct names a demotion minimally requires.
func (s *frameworkCandidateSummary) noteColdCSharpTypeName(n *graph.Node) {
if n.Kind != graph.KindType && n.Kind != graph.KindInterface {
return
}
if !strings.EqualFold(strings.TrimSpace(n.Language), "csharp") {
return
}
s.csharpTypeNames.note(n.Name)
}
const (
frameworkMarkerObserverRegistrar = "observer-channel:registrar"
frameworkMarkerObserverDispatcher = "observer-channel:dispatcher"
frameworkMarkerSwift = "swift-objc-bridge:swift"
frameworkMarkerObjC = "swift-objc-bridge:objc"
frameworkMarkerSvelteKitPage = "sveltekit-load:page"
frameworkMarkerSvelteKitServer = "sveltekit-load:server"
frameworkMarkerPascalSource = "pascal-form:source"
frameworkMarkerPascalForm = "pascal-form:form"
frameworkMarkerGDScript = "gdscript:node"
frameworkMarkerStrictFamilyPrefix = "family-strict:"
frameworkObserverRegistrarRole uint8 = 1
frameworkObserverDispatcherRole uint8 = 2
)
// frameworkSynthNodePreflights names passes with necessary candidate shapes
// visible in the light node projection. Every listed marker is required: a
// missing marker proves the pass cannot produce an edge and avoids its full
// graph scans. These are deliberately one-way gates; a marker hit only keeps
// the pass enabled and does not claim that an edge will be produced.
var frameworkSynthNodePreflights = map[string][]string{
SynthEventChannel: {SynthEventChannel},
SynthSwiftObjC: {frameworkMarkerSwift, frameworkMarkerObjC},
SynthObserverChannel: {SynthObserverChannel},
SynthReactSetState: {SynthReactSetState},
SynthFlutterSetState: {SynthFlutterSetState},
SynthMyBatis: {SynthMyBatis},
SynthSidekiq: {SynthSidekiq},
SynthLaravelEvent: {SynthLaravelEvent},
SynthSwiftUIResolve: {SynthSwiftUIResolve},
SynthUIKitResolve: {SynthUIKitResolve},
SynthVaporResolve: {SynthVaporResolve},
SynthCSharpIfaceDispatch: {SynthCSharpIfaceDispatch},
SynthKMPExpectActual: {SynthKMPExpectActual},
SynthMacroExpansion: {SynthMacroExpansion},
SynthGoFrameRoute: {SynthGoFrameRoute},
SynthSvelteKitLoad: {frameworkMarkerSvelteKitPage, frameworkMarkerSvelteKitServer},
SynthPascalFormName: {frameworkMarkerPascalSource, frameworkMarkerPascalForm},
}
// frameworkSynthFamilyConjunctions strengthens the OR family gate for
// cross-ecosystem bridge passes: every GROUP must have at least one present
// family (OR within a group, AND across groups). A bridge that links two
// sides provably cannot emit when either side's family census is zero.
var frameworkSynthFamilyConjunctions = map[string][][]string{
SynthExpoModules: {{"web"}, {"apple", "jvm"}},
SynthFabric: {{"web"}, {"apple", "jvm"}},
SynthReactNativePair: {{"apple"}, {"jvm"}},
SynthSQLCallsite: {{"sql"}, {"web", "python"}},
}
// frameworkSynthEdgePreflights names passes whose necessary edge evidence is
// visible in the cold EdgeCalls census. Each predicate is a verbatim copy of
// the pass's own admission filter, evaluated on the same edge stream the
// pass reads: a false result proves the pass cannot land, rebind, or stamp
// an edge on this graph, while true only keeps it enabled. Conjunctive with
// the family and node gates; consulted only when the census saw the full
// stream (nil scope).
//
// rtk-query additionally leans on an extractor invariant: every
// rtk_generated_hook node is minted together with a via=rtk-query
// placeholder in the same extraction, so no via edge implies no generated
// hooks and the hook-name branch is inert too. Pinned by
// TestRTKQueryExtractorMintsHookWithViaPlaceholder.
var frameworkSynthEdgePreflights = map[string]func(frameworkEdgeCensus) bool{
SynthObjectRegistry: func(c frameworkEdgeCensus) bool { return c.objectRegistryValue },
SynthNgRxEffect: func(c frameworkEdgeCensus) bool { return c.via[ngrxEffectVia] },
SynthExpressResolve: func(c frameworkEdgeCensus) bool { return c.expressHandlerRef },
SynthReduxThunk: func(c frameworkEdgeCensus) bool { return c.via[reduxThunkVia] },
SynthLaravelEvent: func(c frameworkEdgeCensus) bool { return c.via[laravelEventVia] },
SynthVuexDispatch: func(c frameworkEdgeCensus) bool { return c.via[vuexDispatchVia] },
SynthRTKQuery: func(c frameworkEdgeCensus) bool { return c.via[rtkQueryVia] },
SynthCelery: func(c frameworkEdgeCensus) bool { return c.via[celeryVia] },
SynthSpringEvent: func(c frameworkEdgeCensus) bool { return c.via[springEventVia] },
SynthMediatR: func(c frameworkEdgeCensus) bool { return c.via[mediatrVia] },
SynthReactSetState: func(c frameworkEdgeCensus) bool { return c.setStateTarget },
SynthFlutterSetState: func(c frameworkEdgeCensus) bool { return c.setStateTarget },
SynthRailsResolve: func(c frameworkEdgeCensus) bool { return c.recvConst },
// grpc/temporal were historically ungated: their internal presence
// probes short-circuit the yield but still pay a full EdgeCalls scan
// each (measured 52s + 30s for zero edges on a stub-free workspace).
// The census answers the same predicates in its single shared walk.
SynthGRPCStub: func(c frameworkEdgeCensus) bool { return c.grpcStub },
SynthTemporalStub: func(c frameworkEdgeCensus) bool { return c.temporalVia || c.temporalAnnotation },
}
func recordFrameworkNodeCandidates(markers map[string]int, n *graph.Node, family string) uint8 {
if isPubsubEventNode(n.ID) || isEmitterEventNode(n.ID) {
markers[SynthEventChannel]++
}
language := strings.ToLower(strings.TrimSpace(n.Language))
if strict := languageFamily(language); strict != "" {
markers[frameworkMarkerStrictFamilyPrefix+strict]++
}
switch language {
case "swift":
markers[frameworkMarkerSwift]++
case "objc", "objective-c", "objectivec":
markers[frameworkMarkerObjC]++
case "mybatis":
if n.Kind == graph.KindMethod {
markers[SynthMyBatis]++
}
case "ruby":
if (n.Kind == graph.KindMethod || n.Kind == graph.KindFunction) && n.Name == "perform" {
markers[SynthSidekiq]++
}
case "php":
if (n.Kind == graph.KindMethod || n.Kind == graph.KindFunction) && n.Name == "handle" {
markers[SynthLaravelEvent]++
}
case "gdscript":
// The receiver gate reads only GDScript nodes, so a corpus with
// none provably yields no demotion — and on a scoped run, a
// resolve that touched no GDScript file cannot have changed any
// verdict, since both the call edges and the class index it
// judges against come from `.gd` files.
markers[frameworkMarkerGDScript]++
case "csharp":
if n.Kind == graph.KindInterface {
markers[SynthCSharpIfaceDispatch]++
}
case "kotlin":
markers[SynthKMPExpectActual]++
}
if n.Kind == graph.KindMacro {
markers[SynthMacroExpansion]++
}
// GoFrame route contracts are minted with a stable id namespace; any id
// carrying it (bare or repo-prefixed) proves route candidates exist.
if strings.Contains(n.ID, "route::goframe::") {
markers[SynthGoFrameRoute]++
}
// SvelteKit load synthesis needs both a +page/+layout consumer and a
// server-load producer file; Pascal form binding needs both a source
// unit and a form file. Path/extension shapes are visible in the light
// projection.
if strings.Contains(n.FilePath, "+page.") || strings.Contains(n.FilePath, "+layout.") {
markers[frameworkMarkerSvelteKitPage]++
if strings.Contains(n.FilePath, "+page.server.") || strings.Contains(n.FilePath, "+layout.server.") {
markers[frameworkMarkerSvelteKitServer]++
}
}
if n.Kind == graph.KindFile {
switch strings.ToLower(filepath.Ext(n.FilePath)) {
case ".pas", ".pp", ".dpr", ".lpr":
markers[frameworkMarkerPascalSource]++
case ".dfm", ".lfm", ".fmx":
markers[frameworkMarkerPascalForm]++
}
}
observerRole := uint8(0)
if (n.Kind == graph.KindMethod || n.Kind == graph.KindFunction) && n.Name != "" {
if observerRegistrarRe.MatchString(n.Name) {
markers[frameworkMarkerObserverRegistrar]++
observerRole |= frameworkObserverRegistrarRole
}
if observerDispatcherRe.MatchString(n.Name) {
markers[frameworkMarkerObserverDispatcher]++
observerRole |= frameworkObserverDispatcherRole
}
}
if n.Kind == graph.KindMethod {
switch n.Name {
case "render":
markers[SynthReactSetState]++
case "build":
markers[SynthFlutterSetState]++
}
}
if family != "apple" {
return observerRole
}
name := n.Name
path := strings.ToLower(strings.ReplaceAll(n.FilePath, "\\", "/"))
if strings.HasSuffix(name, "ViewModel") || strings.HasSuffix(name, "View") ||
strings.HasSuffix(name, "Store") || strings.HasSuffix(name, "Manager") ||
strings.Contains(path, "/models/") || strings.Contains(path, "/model/") {
markers[SynthSwiftUIResolve]++
}
if strings.HasSuffix(name, "ViewController") || strings.HasSuffix(name, "Cell") ||
strings.HasSuffix(name, "Delegate") || strings.HasSuffix(name, "DataSource") {
markers[SynthUIKitResolve]++
}
if (strings.HasSuffix(name, "Controller") && !strings.HasSuffix(name, "ViewController")) ||
strings.HasSuffix(name, "Middleware") || strings.Contains(path, "/models/") ||
strings.Contains(path, "/model/") {
markers[SynthVaporResolve]++
}
return observerRole
}
func frameworkLanguageFamily(language string) string {
language = strings.ToLower(strings.TrimSpace(language))
if family := languageFamily(language); family != "" {
return family
}
switch language {
case "go", "golang":
return "go"
case "rust":
return "rust"
case "python", "py":
return "python"
case "ruby":
return "ruby"
case "php":
return "php"
case "dart":
return "dart"
case "sql":
return "sql"
case "pascal", "object-pascal", "object_pascal":
return "pascal"
case "vue", "svelte", "astro":
return "web"
case "objective-c++", "objc++", "objective-cpp":
return "apple"
default:
return ""
}
}
func frameworkSynthUsesScopedCandidates(s FrameworkSynthesizer, scope map[string]bool) bool {
if scope == nil {
return false
}
// Every synthFunc uses either its explicit scoped implementation or the
// bounded frameworkScopedStore candidate view.
if _, ok := s.(synthFunc); ok {
return true
}
_, ok := s.(scopedSynthesizer)
return ok
}
func shouldRunFrameworkSynthesizer(s FrameworkSynthesizer, scope map[string]bool, summary frameworkCandidateSummary) bool {
present := summary.all
markers := summary.allMarkers
if frameworkSynthUsesScopedCandidates(s, scope) {
present = summary.scoped
markers = summary.scopedMarkers
}
if !frameworkSynthNodeGatesPass(s.Name(), present, markers) {
return false
}
if summary.edges.valid {
if preflight := frameworkSynthEdgePreflights[s.Name()]; preflight != nil && !preflight(summary.edges) {
return false
}
}
return true
}
// frameworkSynthNodeGatesPass evaluates the family / conjunction /
// node-marker gates for one pass against the chosen census maps — the
// sub-verdict of shouldRunFrameworkSynthesizer that is already known after
// the light node walk, before the edge census runs. The shared-stream
// dispatcher arms a pass's candidate collectors on exactly this verdict, so
// its armed set is a superset of the passes the full gate will admit.
func frameworkSynthNodeGatesPass(name string, present, markers map[string]int) bool {
if families := frameworkSynthLanguageFamilies[name]; len(families) > 0 {
found := false
for _, family := range families {
if present[family] > 0 {
found = true
break
}
}
if !found {
return false
}
}
for _, groups := range frameworkSynthFamilyConjunctions[name] {
found := false
for _, family := range groups {
if present[family] > 0 {
found = true
break
}
}
if !found {
return false
}
}
for _, marker := range frameworkSynthNodePreflights[name] {
if markers[marker] == 0 {
return false
}
}
return true
}
// defaultFrameworkSynthesizers returns the registered framework
// synthesizers in run order. Order is load-bearing: every synthesizer
// here runs after InferImplements/InferOverrides (some depend on the
// EdgeImplements edges they produce) and before DetectCrossRepoEdges (so
// a cross-repo synthesized call gets its parallel cross_repo_calls edge).
// Native-bridge resolvers append to this slice.
func defaultFrameworkSynthesizers() []FrameworkSynthesizer {
return []FrameworkSynthesizer{
synthFunc{name: SynthGRPCStub, fn: ResolveGRPCStubCalls, candFn: resolveGRPCStubCalls},
synthFunc{name: SynthTemporalStub, fn: ResolveTemporalCalls, candFn: resolveTemporalCalls},
synthFunc{name: SynthEventChannel, fn: ResolveEventChannelCalls},
synthFunc{name: SynthSwiftObjC, fn: ResolveSwiftObjCBridge},
synthFunc{name: SynthReactNative, fn: ResolveReactNativeBridge},
synthFunc{name: SynthReactNativePair, fn: ResolveReactNativeNativePairing},
synthFunc{name: SynthObserverChannel, fn: ResolveObserverChannelCalls},
synthFunc{name: SynthClosureCollection, fn: ResolveClosureCollectionCalls},
synthFunc{name: SynthReactSetState, fn: ResolveReactSetStateCalls},
synthFunc{name: SynthFlutterSetState, fn: ResolveFlutterSetStateCalls},
synthFunc{name: SynthKMPExpectActual, fn: ResolveKMPExpectActual},
synthFunc{name: SynthExpoModules, fn: ResolveExpoModuleBridge},
synthFunc{name: SynthFabric, fn: ResolveFabricComponents},
synthFunc{name: SynthMyBatis, fn: ResolveMyBatisCalls},
synthFunc{name: SynthSQLCallsite, fn: ResolveSQLCallsites},
// Store-factory (Zustand/Redux/Pinia/MobX) indirect action calls —
// binds getState()-chain and destructured calls to the action node.
synthFunc{name: SynthStoreFactory, fn: ResolveStoreFactoryCalls, candFn: resolveStoreFactoryCalls},
// Redux Toolkit createAsyncThunk dispatch chains: a thunk →
// each action/thunk it dispatches from its payload-creator body.
// After store-factory so its action nodes are indexed for the
// thunk → reducer cross-link.
synthFunc{name: SynthReduxThunk, fn: ResolveReduxThunkCalls},
// NgRx effects: a createEffect(() => actions$.pipe(ofType(X))) effect ->
// the action X it reacts to. After the store/thunk passes so action
// creator nodes are indexed.
synthFunc{name: SynthNgRxEffect, fn: ResolveNgRxEffects},
// Object-literal command/handler registry dispatch →
// `new registry[key]().execute()`. Runs before the speculative
// pass so a claimed dispatch site suppresses the hidden best-guess.
synthFunc{name: SynthObjectRegistry, fn: ResolveObjectRegistryCalls},
// RTK Query generated-hook → createApi endpoint, and component →
// generated hook. Typed tier: the hook naming is RTK-contractual.
synthFunc{name: SynthRTKQuery, fn: ResolveRTKQueryCalls},
// Vuex string-keyed dispatch/commit → action/mutation, with
// module-namespace disambiguation.
synthFunc{name: SynthVuexDispatch, fn: ResolveVuexDispatchCalls},
// Celery task dispatch: `task.delay()` / `send_task("name")` →
// the decorator-gated task function. Typed tier.
synthFunc{name: SynthCelery, fn: ResolveCeleryCalls},
// Spring application events: publishEvent(new X()) → every
// @EventListener / ApplicationListener<X>, type-keyed fan-out.
synthFunc{name: SynthSpringEvent, fn: ResolveSpringEventCalls},
// MediatR CQRS dispatch: Send(new X()) → the IRequestHandler<X>
// Handle, Publish(new X()) → every INotificationHandler<X>.
synthFunc{name: SynthMediatR, fn: ResolveMediatRCalls},
// Autofac AsImplementedInterfaces(): expand each extractor-emitted
// marker into one useClass provides binding per interface the impl
// declares. After the implements-producing passes so the join reads
// the settled hierarchy.
synthFunc{name: SynthCSharpDIIfaces, fn: ResolveCSharpDIImplementedInterfaces, scopedFn: ResolveCSharpDIImplementedInterfacesScoped},
// C# member-level interface dispatch: a call bound to an interface
// member fans out to the same-named member on each in-repo
// implementation, at the ast_inferred tier so it rides in the default
// find_usages / get_callers result. After the implements-producing
// passes so the impl fan-out is complete.
synthFunc{name: SynthCSharpIfaceDispatch, fn: ResolveCSharpInterfaceDispatch, scopedFn: ResolveCSharpInterfaceDispatchScoped},
// Sidekiq job dispatch: Worker.perform_async(...) → the worker's
// perform, namespace-aware. Include-gated, typed tier.
synthFunc{name: SynthSidekiq, fn: ResolveSidekiqCalls},
// Laravel events: event(new X()) / X::dispatch() → every listener
// handle(X), from the Listeners convention and the $listen map.
synthFunc{name: SynthLaravelEvent, fn: ResolveLaravelEventCalls},
// C/C++ function-pointer dispatch: a fn registered into a struct's
// fn-pointer field → the indirect recv->field() call, keyed by
// (struct type, field) with a field-copy fixpoint.
synthFunc{name: SynthFnPointerDispatch, fn: ResolveFnPointerDispatch, candFn: resolveFnPointerDispatch},
// C/C++ function-like macro expansion: a macro invocation
// `CALL_M(o)` → each call hidden in the macro's replacement list,
// attributed to the use-site line so a forward call walk shows the
// call where the macro is invoked, not at its `#define`.
synthFunc{name: SynthMacroExpansion, fn: ResolveMacroExpansionCalls, candFn: resolveMacroExpansionCalls},
// Gin middleware-chain dispatcher → registered handlers. Bridges the
// `c.handlers[idx](c)` indirection so ServeHTTP→handler reachability
// flows; repo-scoped, gated on a dispatcher existing.
synthFunc{name: SynthGinMiddleware, fn: ResolveGinMiddlewareCalls},
// Express named-handler resolution: middleware idents and
// XController.method args bound by directory convention.
synthFunc{name: SynthExpressResolve, fn: ResolveExpressHandlers},
// React custom-hook / context resolution: a `useAuth()` call binds to
// its /hooks/ definition; a `*Context`/`*Provider` reference binds to
// /context/ or /providers/, with the suffix-strip fallback.
synthFunc{name: SynthReactResolve, fn: ResolveReactHooksContext, candFn: resolveReactHooksContext},
// FastAPI dependency / router fallback: a residual `Depends(get_db)`
// binds to a /dependencies/ provider, an `include_router(api_router)`
// to a /routers/ definition — only when reference resolution left the
// target unresolved.
synthFunc{name: SynthFastAPIResolve, fn: ResolveFastAPIDeps, candFn: resolveFastAPIDeps},
// Rails receiver-constant resolution: a `UserService.perform` /
// `User.find` / `ApplicationHelper.fmt` call binds to the directory-
// located service / model / helper definition named by its receiver.
synthFunc{name: SynthRailsResolve, fn: ResolveRailsRefs, candFn: resolveRailsRefs},
// Godot autoload resolution: a `Game.set_speed()` call binds to
// the script `project.godot` declares the singleton `Game` to
// be — the only place that mapping exists.
synthFunc{name: SynthGodotAutoload, fn: ResolveGDScriptAutoloads},
// Godot preload aliases: a `const Persist = preload("res://…")`
// names a script file-locally, and `Persist.snapshot()` is then
// used exactly like a global — the standard way to reach a
// static-utility script that declares no `class_name`.
synthFunc{name: SynthGodotPreloadAlias, fn: ResolveGDScriptPreloadAliases},
// Godot scene connections: a `.tscn` `[connection … method="…"]`
// block wires a signal to a method of the script attached to the
// target node. The method is named as a bare string, so nothing in
// the scripts records the link.
synthFunc{name: SynthGodotConnection, fn: ResolveGodotSceneConnections},
// SwiftUI directory-convention fallback: a residual `*View` /
// `*ViewModel` / `*Store` / `*Manager` / PascalCase-model reference
// binds to its /Views/ /ViewModels/ /Stores/ /Models/ definition.
synthFunc{name: SynthSwiftUIResolve, fn: ResolveSwiftUIRefs},
// UIKit directory-convention fallback: a residual `*ViewController` /
// `*Cell` / `*Delegate` / `*DataSource` reference binds to its
// /ViewControllers/ /Cells/ /Delegates/ definition.
synthFunc{name: SynthUIKitResolve, fn: ResolveUIKitRefs, candFn: resolveUIKitRefs},
// Vapor directory-convention fallback: a residual `*Controller` /
// `*Middleware` reference binds to its /Controllers/ /Middleware/
// definition. After UIKit so `*ViewController` binds there first.
synthFunc{name: SynthVaporResolve, fn: ResolveVaporRefs},
// GoFrame reflective route → controller method, joined by the
// method's request-struct type rather than its name.
synthFunc{name: SynthGoFrameRoute, fn: ResolveGoFrameRoutes},
// SvelteKit +page ↔ +page.server load pairing: a route's page component
// reaches its server data loader so a trace flows page→load. Repo-scoped.
synthFunc{name: SynthSvelteKitLoad, fn: ResolveSvelteKitLoad},
// Rust impl-block / self-receiver / module-path resolution
// completion. Runs in the same settle window so residual
// unresolved Rust calls land before external-call synthesis
// classifies the rest as external.
synthFunc{name: SynthRustScope, fn: ResolveRustScopeCalls, candFn: resolveRustScopeCalls},
// After rust-scope and the implements/extends-producing passes so the
// cross-file factory-chain walk + conformance hop see settled edges.
synthFunc{name: SynthFactoryChain, fn: ResolveFactoryChains, candFn: resolveFactoryChains},
// Function-as-value callback registration — binds each captured
// value-position function identifier to its same-file definition and
// drops unbound candidates. The per-language capture feeds it via
// placeholder edges; the pass is inert until those land.
synthFunc{name: SynthFnValue, fn: ResolveFnValueCallbacks},
// Pascal unit ↔ form (.pas/.dfm) pairing by same-dir basename.
synthFunc{name: SynthPascalFormName, fn: ResolvePascalForms},
// Same-file distinctive value references → EdgeReads to the constant,
// so a config constant's blast radius reaches every reader.
synthFunc{name: SynthValueRefName, fn: ResolveValueRefs},
}
}
// SynthCount is the per-synthesizer result row in a FrameworkSynthReport.
type SynthCount struct {
Name string `json:"name"`
// Edges is the synthesizer's legacy attempted/landed count. Some passes
// include already-persisted idempotent results, so it is not an inserted-row
// count; the name remains for API compatibility.
Edges int `json:"edges"`
// Millis is how long this synthesizer's Synthesize call took. Named
// passes that land 0 edges are not free — many scan a shared edge/node
// kind across the whole graph before concluding there is nothing to
// bind — so this rides on every row, not just the ones with edges.
Millis int64 `json:"ms,omitempty"`
// ScopeRows/ScopeBytes expose the bounded seed plus this pass's private
// dependency expansion. They make accidental cross-pass widening visible
// without retaining candidate objects after the pass completes.
ScopeRows int `json:"scope_rows,omitempty"`
ScopeBytes int `json:"scope_bytes,omitempty"`
}
// FrameworkSynthReport is the aggregate result of one
// RunFrameworkSynthesizers invocation.
type FrameworkSynthReport struct {
// Total preserves the legacy sum of per-pass attempted/landed counts. It is
// not a durable inserted-edge delta; callers should label it accordingly.
Total int `json:"total"`
Per []SynthCount `json:"per_synthesizer"`
// Gated counts synthesized reference/import edges dropped by the
// cross-language-family gate (coincidental PascalCase collisions across
// two known, different families; bridge synthesizers are exempt).
Gated int `json:"gated_cross_family,omitempty"`
// ReceiverGated counts C# member-call edges demoted to the speculative
// tier because they attach to a same-named member of a type unrelated to
// the edge's receiver_type.
ReceiverGated int `json:"receiver_type_gated,omitempty"`
// GDScriptReceiverGated counts the same demotion for GDScript, where the
// receiver is stated at every call site and a same-named method in the
// caller's own directory is the standing phantom.
GDScriptReceiverGated int `json:"gdscript_receiver_type_gated,omitempty"`
// GateMillis/ClaimMillis/DemoteMillis time the three tail passes that
// run once (not per-synthesizer) after the main loop, so a slow one
// doesn't hide behind the loop's aggregate elapsed.
GateMillis int64 `json:"gate_ms,omitempty"`
ClaimMillis int64 `json:"claim_ms,omitempty"`
DemoteMillis int64 `json:"demote_ms,omitempty"`
// CensusMillis times the admission census that runs before the loop. It
// walks the node stream plus the cold EdgeCalls census; against a
// checkpointed store it costs ~11s, but a measured cold run spent ~533s
// here with every synthesizer gated to zero — the census, not the
// synthesizers, owned the pass. It must never be silent again.
CensusMillis int64 `json:"census_ms,omitempty"`
// ScopeMillis times the scoped-store seed construction between the census
// and the loop. ScopeRows/ScopeBytes describe that immutable seed; each
// SynthCount then reports the seed plus only its private expansion.
ScopeMillis int64 `json:"scope_ms,omitempty"`
ScopeRows int `json:"scope_rows,omitempty"`
ScopeBytes int `json:"scope_bytes,omitempty"`
// FullReadCache reports the bounded immutable node/member projection cache
// used only by cold/full executions. Scoped incremental passes keep their
// exact frontier store and leave this zero-valued.
FullReadCache FrameworkFullReadCacheStats `json:"full_read_cache,omitempty"`
}
// scopedSynthesizer is the optional capability a FrameworkSynthesizer exposes
// when it can restrict its candidate scan to a changed-repo prefix set. The
// driver consults it only when a scope is armed; a synthesizer that does not
// implement it runs whole-graph, which is always correct.
type scopedSynthesizer interface {
synthesizeScoped(g graph.Store, scope map[string]bool) int
}
// RunFrameworkSynthesizers runs every registered framework synthesizer
// over g, in registration order, and returns the per-synthesizer and
// total landed-edge counts. A nil graph is a no-op.
func RunFrameworkSynthesizers(g graph.Store) FrameworkSynthReport {
return RunFrameworkSynthesizersScoped(g, nil)
}
// RunFrameworkSynthesizersScoped is RunFrameworkSynthesizers with an armed
// changed-repo scope: each synthesizer that implements scopedSynthesizer
// narrows its candidate scan to those repos, the rest run whole-graph. A nil
// scope uses the whole-graph candidate census; language-specific passes proven
// irrelevant are skipped, while generic or unaudited passes always run. The
// claiming-resolver, family-gate, C# interface-dispatch, and receiver-gate tail
// passes use the changed repositories plus their exact reverse dependency
// frontier; nil scope retains full/cold whole-graph reconciliation.
func RunFrameworkSynthesizersScoped(g graph.Store, scope map[string]bool) FrameworkSynthReport {
return runFrameworkSynthesizersScoped(g, scope, nil, false, true)
}
// RunFrameworkSynthesizersScopedWithCensus is the full-coverage batch form:
// the caller (the daemon's cold / full-reconciliation warmup) attests that
// the scope covers every tracked repository, so the admission census may be
// built from the RAW whole store even though synthesizer execution keeps the
// scoped view. The attestation must come from the repo registry's owner —
// it is never inferred here from the scope's size.
func RunFrameworkSynthesizersScopedWithCensus(
g graph.Store,
scope map[string]bool,
censusEligible bool,
) FrameworkSynthReport {
return runFrameworkSynthesizersScoped(g, scope, nil, censusEligible, true)
}
// RunFrameworkSynthesizersScopedForFiles is the exact incremental form. The
// changed-file frontier owns candidate scans; incident incoming edges and exact
// name dependencies are admitted by frameworkScopedStore so target-side edits
// reconcile without widening to the repository corpus.
func RunFrameworkSynthesizersScopedForFiles(
g graph.Store,
scope map[string]bool,
filePaths []string,
csharpHierarchyChanged bool,
) FrameworkSynthReport {
return runFrameworkSynthesizersScoped(g, scope, filePaths, false, csharpHierarchyChanged)
}
func frameworkScopeForFiles(
g graph.Store,
scope map[string]bool,
filePaths []string,
) map[string]bool {
if scope != nil || g == nil || len(filePaths) == 0 {
return scope
}
recovered := map[string]bool{}
for _, nodes := range g.GetFileNodesByPaths(filePaths) {
for _, node := range nodes {
if node != nil {
recovered[node.RepoPrefix] = true
}
}
}
return recovered
}
func runFrameworkSynthesizersScoped(
g graph.Store,
scope map[string]bool,
filePaths []string,
censusEligible bool,
csharpHierarchyChanged bool,
) FrameworkSynthReport {
rep := FrameworkSynthReport{}
if g == nil {
return rep
}
// A changed-file frontier is always partial, even when a legacy caller lost
// its repository prefix (notably the empty-prefix single-repository shape).
// Recover the owning prefixes from the exact file rows so tail claimers keep
// their repository boundary without promoting the census to a global scan.
effectiveScope := frameworkScopeForFiles(g, scope, filePaths)
censusStart := time.Now()
candidates := summarizeFrameworkCandidatesCensus(g, effectiveScope, filePaths, censusEligible)
rep.CensusMillis = time.Since(censusStart).Milliseconds()
// A full-census attestation means the supplied repository scope covers the
// entire store. Execute it through the established nil-scope paths: bespoke
// scoped synthesizers and tail passes otherwise turn the all-repository set
// into repeated repo -> node -> edge probes. True partial runs retain their
// repository and file frontier unchanged.
executionScope := effectiveScope
if candidates.fullCensus {
executionScope = nil
}
scopeStart := time.Now()
var genericSeed *frameworkScopedSeed
var fullReadCache *frameworkFullReadCache
if executionScope != nil {
genericSeed = newFrameworkScopedSeed(g, executionScope, filePaths)
rep.ScopeRows = genericSeed.retainedRows
rep.ScopeBytes = genericSeed.retainedBytes
} else {
// Node declarations are immutable throughout the framework registry.
// Share their decoded projections across passes under a hard run-local
// budget; the per-pass edge facade invalidates on any future node/member
// mutation so this optimization cannot return stale declaration state.
fullReadCache = newFrameworkFullReadCache()
}
rep.ScopeMillis = time.Since(scopeStart).Milliseconds()
for _, s := range defaultFrameworkSynthesizers() {
start := time.Now()
var n int
var bundle *frameworkPassCandidates
var passScope *frameworkScopedStore
if shouldRunFrameworkSynthesizer(s, executionScope, candidates) {
if sf, ok := s.(synthFunc); ok {
bundle = candidates.streams.passStreams(g, sf.name)
switch {
case sf.candFn != nil && bundle != nil:
// Shared-stream form. streams exist only on a full-census
// run, where the execution store is the raw g for both
// the nil-scope and the attested full-coverage shapes.
n = runLegacyFrameworkSynthWithCache(g, fullReadCache, func(store graph.Store) int {
return sf.candFn(store, bundle)
})
case executionScope == nil:
n = runLegacyFrameworkSynthWithCache(g, fullReadCache, sf.fn)
case sf.scopedFn != nil:
n = sf.scopedFn(g, executionScope)
default:
passScope = genericSeed.newPassStore()
n = runLegacyFrameworkSynth(passScope, sf.fn)
}
} else if ss, ok := s.(scopedSynthesizer); ok {
n = runLegacyFrameworkSynthWithCache(g, fullReadCache, func(store graph.Store) int {
return ss.synthesizeScoped(store, executionScope)
})
} else if executionScope == nil {
n = runLegacyFrameworkSynthWithCache(g, fullReadCache, s.Synthesize)
} else {
panic("framework partial run has an unscoped synthesizer: " + s.Name())
}
}
count := SynthCount{Name: s.Name(), Edges: n, Millis: time.Since(start).Milliseconds()}
if passScope != nil {
stats := passScope.stats()
count.ScopeRows = stats.RetainedRows
count.ScopeBytes = stats.RetainedBytes
}
rep.Per = append(rep.Per, count)
rep.Total += n
candidates.streams.releasePass(s.Name(), bundle)
}
// Capture observability before dropping the run-local cache. Tail gates and
// claiming resolvers use different projections and must not prolong its
// lifetime beyond the serial framework registry.
rep.FullReadCache = fullReadCache.stats()
fullReadCache = nil
// The registry consumed or discarded every armed buffer. Release the
// shared node snapshot before the independent tail gates and claimers run.
candidates.streams = nil
// Drop coincidental cross-language-family reference/import results before
// the claiming resolvers run, so a gated edge cannot be mistaken for a
// resolved placeholder downstream. Bridge synthesizers are exempt.
gateStart := time.Now()
if frameworkFamilyGateNeeded(executionScope, candidates) {
rep.Gated = applyFrameworkFamilyGateScopedForFiles(g, executionScope, filePaths)
}
rep.GateMillis = time.Since(gateStart).Milliseconds()
// Claiming resolvers run last — after every framework synthesizer has
// had its chance to consume a pre-stamped placeholder, but before
// external-call synthesis classifies the residual unresolved refs as
// external. Reported in registration order for determinism.
claimStart := time.Now()
claimed := RunClaimingResolversScoped(g, executionScope)
rep.ClaimMillis = time.Since(claimStart).Milliseconds()
for _, r := range defaultClaimingResolvers() {
n := claimed[r.Name()]
rep.Per = append(rep.Per, SynthCount{Name: r.Name(), Edges: n})
rep.Total += n
}
// Receiver-type gate runs last: it corrects (demotes) already-bound C#
// member calls, so it must see the settled call graph.
demoteStart := time.Now()
if frameworkReceiverGateNeeded(executionScope, candidates) {
rep.ReceiverGated = demoteCSharpMisattributedMemberCallsScopedForFiles(
g, executionScope, filePaths, csharpHierarchyChanged,
)
}
// The GDScript gate runs in the same slot and for the same reason: it
// corrects already-bound member calls, so it must see the settled call
// graph — after the Godot autoload / preload-alias binders above have
// had their chance to claim an edge the gate would otherwise judge.
if frameworkGDScriptGateNeeded(candidates) {
rep.GDScriptReceiverGated = DemoteGDScriptReceiverMismatches(g)
}
rep.DemoteMillis = time.Since(demoteStart).Milliseconds()
return rep
}
// frameworkStrictFamilies enumerates every non-empty languageFamily value.
// The census records one strict-family marker per node so the tail gates can
// count how many distinct families are present; keep in sync with
// languageFamily's switch arms.
var frameworkStrictFamilies = []string{"jvm", "apple", "web", "c", "dotnet"}
// frameworkFamilyGateNeeded reports whether the cross-family gate can drop
// an edge. A drop requires both endpoint nodes to map to two different
// non-empty strict families, and the cold census walks every node — so
// fewer than two distinct strict families proves the gate returns zero on
// any graph. Uses the strict languageFamily marker, not the framework
// family census: summary.all["web"] can be satisfied entirely by
// vue/svelte nodes whose strict family is empty and which can never
// trigger a drop. Scoped runs always run the gate — a scoped census does
// not walk off-scope endpoint nodes.
func frameworkFamilyGateNeeded(_ map[string]bool, summary frameworkCandidateSummary) bool {
if !summary.fullCensus {
return true
}
distinct := 0
for _, family := range frameworkStrictFamilies {
if summary.allMarkers[frameworkMarkerStrictFamilyPrefix+family] > 0 {
distinct++
}
}
return distinct >= 2
}
// frameworkReceiverGateNeeded reports whether the C# receiver gate can
// demote an edge. A demotion requires the edge's receiver_type and the
// target's receiver to be two different names, each resolving to an indexed
// C# type/interface node — so a cold census with fewer than two distinct C#
// type/interface names proves the gate returns zero on any graph. Scoped
// runs always run the gate — the gate's name index is whole-graph while a
// scoped census is not.
// frameworkGDScriptGateNeeded reports whether the GDScript receiver gate
// can change anything. Unlike the family/receiver tails, a scoped census
// is enough here: the gate reads only GDScript nodes and edges, so a
// resolve whose candidate set contains no GDScript node cannot have moved
// a verdict. The gate runs whole-graph when it runs, so keeping it off a
// resolve that touched no `.gd` file is what keeps a warm restart on a
// polyglot workspace from paying for it.
func frameworkGDScriptGateNeeded(summary frameworkCandidateSummary) bool {
if summary.fullCensus {
return summary.allMarkers[frameworkMarkerGDScript] > 0
}
return summary.scopedMarkers[frameworkMarkerGDScript] > 0
}
func frameworkReceiverGateNeeded(_ map[string]bool, summary frameworkCandidateSummary) bool {
if !summary.fullCensus {
return true
}
return summary.csharpTypeNames.count >= 2
}
// ClaimingResolver retroactively claims a residual unresolved reference —
// one naming no declared symbol — that the extractor could not pre-tag, and
// rewrites it to a framework-known target. This is the generic
// claimsReference hook: a resolver offers a cheap name-vocabulary pre-filter
// (Claims) and, when it wins, rebinds the edge (Resolve). It runs before
// external-call synthesis would otherwise discard the reference as external.
type ClaimingResolver interface {
// Name is the stable provenance label stamped on the rebound edge.
Name() string
// Claims reports whether this resolver wants the unresolved edge — a
// cheap pre-filter on the reference's vocabulary, no graph work.
Claims(e *graph.Edge) bool
// Resolve rebinds e.To to a concrete target, returning true on a hit.
Resolve(g graph.Store, e *graph.Edge) bool
}
// batchClaimingResolver is the set-oriented form used by the framework tail.
// The returned map contains only edges actually rebound by this resolver.
type batchClaimingResolver interface {
ResolveBatch(g graph.Store, edges []*graph.Edge) map[*graph.Edge]bool
}
// claimTargetVocabulary is the optional admission probe a ClaimingResolver
// exposes. RequiredTargetNames lists the node names the resolver binds
// claims to; AdmitsTarget reports whether one indexed node retrieved for
// such a name satisfies the resolver's bind-time shape. Before the tail
// pays the unresolved-edge collection scan, each resolver's vocabulary is
// probed against the live name index — the same index the resolver reads at
// bind time — so an absent vocabulary proves the resolver cannot claim on
// this graph. A resolver without the interface is always admissible.
type claimTargetVocabulary interface {
RequiredTargetNames() []string
AdmitsTarget(n *graph.Node) bool
}
// claimEdgeVocabulary exhaustively enumerates the unresolved names a resolver
// can claim. Scoped runs use these names for reverse-index lookups; full runs
// filter the bounded unresolved-identity scan before exact row refetch. Neither
// path decodes unrelated call/reference payloads. Resolvers with an unbounded
// vocabulary must omit this interface so discovery fails open to the complete
// unresolved-edge scan.
type claimEdgeVocabulary interface {
ClaimedUnresolvedNames() []string
}
// RequiredTargetNames: a Django descriptor claim binds only to an indexed
// __iter__ method; without one, ResolveBatch resolves nothing.
func (DjangoDescriptorResolver) RequiredTargetNames() []string {
return []string{"__iter__"}
}
func (DjangoDescriptorResolver) ClaimedUnresolvedNames() []string {
return []string{"_iterable_class", "*._iterable_class"}
}
// AdmitsTarget reports whether an __iter__ candidate has the method shape
// ResolveBatch indexes into iterMethods.
func (DjangoDescriptorResolver) AdmitsTarget(n *graph.Node) bool {
return n != nil && n.Kind == graph.KindMethod
}
// claimingResolverAdmissible evaluates a resolver's declared vocabulary with
// one indexed name lookup. An inadmissible resolver cannot claim any edge,
// so dropping it leaves every other resolver's candidate set unchanged.
func claimingResolverAdmissible(g graph.Store, r ClaimingResolver) bool {
vocab, ok := r.(claimTargetVocabulary)
if !ok {
return true
}
names := vocab.RequiredTargetNames()
if len(names) == 0 {
return true
}
for _, nodes := range g.FindNodesByNames(names) {
for _, n := range nodes {
if n != nil && vocab.AdmitsTarget(n) {
return true
}
}
}
return false
}
func scopedClaimingCandidates(g graph.Store, scope map[string]bool, resolvers []ClaimingResolver) []*graph.Edge {
names := make([]string, 0, len(resolvers))
seenNames := make(map[string]struct{}, len(resolvers))
for _, resolver := range resolvers {
vocabulary, ok := resolver.(claimEdgeVocabulary)
if !ok {
return unresolvedClaimingCandidates(g, scope)
}
for _, name := range vocabulary.ClaimedUnresolvedNames() {
if name == "" {
continue
}
if _, duplicate := seenNames[name]; duplicate {
continue
}
seenNames[name] = struct{}{}
names = append(names, name)
}
}
if len(names) == 0 {
return nil
}
materialize := func(identities []graph.EdgeIdentity) []*graph.Edge {
current := findFrameworkEdgesByIdentities(g, identities)
pending := make([]*graph.Edge, 0, len(identities))
for _, identity := range identities {
edge := current[identity]
if claimingCandidateInScope(edge, scope, resolvers) {
pending = append(pending, edge)
}
}
return pending
}
if scope == nil {
scanner, ok := g.(graph.UnresolvedEdgeIdentityBatchScanner)
if !ok {
return unresolvedClaimingCandidates(g, scope)
}
identities := make([]graph.EdgeIdentity, 0)
scanner.ScanUnresolvedEdgeIdentitiesBatched(
[]graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}, 512,
func(batch []graph.EdgeIdentity) bool {
for _, identity := range batch {
if _, claimedName := seenNames[graph.UnresolvedName(identity.To)]; claimedName {
identities = append(identities, identity)
}
}
return true
},
)
return materialize(identities)
}
prefixes := frameworkScopePrefixes(scope)
targetIDs := make([]string, 0, len(names)*(len(prefixes)+1))
for _, name := range names {
targetIDs = append(targetIDs, graph.UnresolvedMarker+name)
for _, prefix := range prefixes {
targetIDs = append(targetIDs, prefix+"::"+graph.UnresolvedMarker+name)
}
}
incoming := g.GetInEdgesByNodeIDs(targetIDs)
identities := make([]graph.EdgeIdentity, 0)
seen := make(map[graph.EdgeIdentity]struct{})
for _, targetID := range targetIDs {
for _, edge := range incoming[targetID] {
if !claimingCandidateInScope(edge, scope, resolvers) {
continue
}
identity := graph.EdgeIdentityFor(edge)
if _, duplicate := seen[identity]; duplicate {
continue
}
seen[identity] = struct{}{}
identities = append(identities, identity)
}
}
return materialize(identities)
}
func unresolvedClaimingCandidates(g graph.Store, scope map[string]bool) []*graph.Edge {
var pending []*graph.Edge
for _, edge := range frameworkRepoEdges(g, scope, graph.EdgeCalls, graph.EdgeReferences) {
if edge != nil && edge.To != "" && graph.IsUnresolvedTarget(edge.To) {
pending = append(pending, edge)
}
}
return pending
}
func claimingCandidateInScope(edge *graph.Edge, scope map[string]bool, resolvers []ClaimingResolver) bool {
if edge == nil || edge.To == "" || !graph.IsUnresolvedTarget(edge.To) {
return false
}
if edge.Kind != graph.EdgeCalls && edge.Kind != graph.EdgeReferences {
return false
}
if scope != nil {
prefix := graph.RepoPrefixOfID(edge.From)
if !scope[prefix] {
prefix = graph.RepoPrefixOfID(edge.FilePath)
if !scope[prefix] {
return false
}
}
}
for _, resolver := range resolvers {
if resolver.Claims(edge) {
return true
}
}
return false
}
// defaultClaimingResolvers returns the registered claiming resolvers, in
// offer order.
func defaultClaimingResolvers() []ClaimingResolver {
return []ClaimingResolver{
DjangoDescriptorResolver{},
}
}
// RunClaimingResolvers offers every residual unresolved EdgeCalls /
// EdgeReferences to the claiming resolvers; the first whose Claims pre-filter
// passes and whose Resolve lands a target wins. Returns the per-resolver
// count of claimed edges. Unresolved edges are collected before resolving so
// a resolver's ReindexEdges does not mutate a live iteration.
func RunClaimingResolvers(g graph.Store) map[string]int {
return RunClaimingResolversScoped(g, nil)
}
// RunClaimingResolversScoped limits partial-index work to unresolved calls and
// references sourced by changed repositories. Resolver precedence is retained:
// each registered resolver receives only edges not claimed by an earlier one.
func RunClaimingResolversScoped(g graph.Store, scope map[string]bool) map[string]int {
out := map[string]int{}
if g == nil {
return out
}
resolvers := defaultClaimingResolvers()
if len(resolvers) == 0 {
return out
}
admissible := make([]ClaimingResolver, 0, len(resolvers))
for _, r := range resolvers {
if claimingResolverAdmissible(g, r) {
admissible = append(admissible, r)
}
}
if len(admissible) == 0 {
return out
}
pending := scopedClaimingCandidates(g, scope, admissible)
claimed := make(map[*graph.Edge]bool)
for _, r := range admissible {
candidates := make([]*graph.Edge, 0, len(pending))
for _, e := range pending {
if !claimed[e] && r.Claims(e) {
candidates = append(candidates, e)
}
}
if len(candidates) == 0 {
continue
}
if batcher, ok := r.(batchClaimingResolver); ok {
for edge := range batcher.ResolveBatch(g, candidates) {
if edge != nil && !claimed[edge] {
claimed[edge] = true
out[r.Name()]++
}
}
continue
}
for _, e := range candidates {
if r.Resolve(g, e) {
claimed[e] = true
out[r.Name()]++
}
}
}
return out
}