001package gudusoft.gsqlparser.dlineage.dynamicsql;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.TStatementList;
005import gudusoft.gsqlparser.dlineage.dynamicsql.RoutineSummaryEdge.Endpoint;
006import gudusoft.gsqlparser.dlineage.dynamicsql.RoutineSummaryEdge.EndpointKind;
007import gudusoft.gsqlparser.nodes.TObjectName;
008
009import java.util.ArrayDeque;
010import java.util.ArrayList;
011import java.util.Collections;
012import java.util.Comparator;
013import java.util.Deque;
014import java.util.HashMap;
015import java.util.HashSet;
016import java.util.LinkedHashMap;
017import java.util.LinkedHashSet;
018import java.util.List;
019import java.util.Map;
020import java.util.Set;
021import java.util.TreeSet;
022
023/**
024 * B3 (design {@code routine-summary-scc-design.md} §2.3/§2.6): SHADOW-mode
025 * summary composition into an ISOLATED observation model. Nothing here touches the
026 * LEGACY analysis — the applier runs its own per-definition extractions,
027 * binds call sites through the {@link CatalogRoutineCallBinder}, and emits
028 * the composed cross-routine edges as a standalone edge set tagged
029 * {@code origin=SUMMARY_COMPOSED}. The {@link ShadowLeafEdgeComparator}
030 * classifies that set against the LEGACY run's leaf projection.
031 *
032 * <p>Composition rules (the G1 capability):
033 * <ul>
034 * <li><b>IN</b>: callee edge {@code formal_p → B} composes with the caller's
035 *     boundary sources feeding the actual bound to {@code p} — the fan-in of
036 *     the actual expression's leaves, resolved through the caller's OWN
037 *     closure (locals are quotiented out of summaries; the per-call-site
038 *     reach on {@link CallSiteRef} is exactly what composition needs).</li>
039 * <li><b>OUT</b>: callee edge {@code X → formal_p(OUT)} composes onto the
040 *     caller boundary endpoints reachable FROM the bound writable lvalue.
041 *     When {@code X} is itself an IN formal of the callee, it chains through
042 *     the caller sources of ITS actual (IN→OUT pass-through).</li>
043 * <li><b>RESULT SET</b>: callee edge {@code X → resultset column} maps
044 *     positionally onto INSERT…EXEC target columns (§2.3 rule 3), using the
045 *     callee summary's recorded result-set shape.</li>
046 * <li>Relation flavors compose as the closure does: a chain is {@code fdd}
047 *     only when every leg is; any {@code fdr} leg makes it {@code fdr}.</li>
048 * <li>AMBIGUOUS / UNRESOLVED / duplicate-identity callees bind nothing —
049 *     a diagnostic records each skip (never guessed).</li>
050 * </ul>
051 */
052public final class ShadowSummaryApplier {
053
054    /** Stable reason attached to every composed observation until call
055     *  validity, type compatibility, actual mutability, and execution context
056     *  are exhaustively proven. */
057    public static final String APPLICATION_DEFERRED_CALL_VALIDITY_NOT_PROVEN =
058            "APPLICATION_DEFERRED_CALL_VALIDITY_NOT_PROVEN";
059
060    /** Publication gate attached to every composed edge. No dataflow model
061     *  is mutated by this class. Current output is observation-only;
062     *  REPLACEMENT_ELIGIBLE is reserved and unreachable. */
063    public enum ApplicationGate {
064        /** Reserved for a future exhaustively-preflighted projection path.
065         *  Current public results never emit this verdict. */
066        REPLACEMENT_ELIGIBLE,
067        /** Candidate edge for evaluation; not authorized for merging. */
068        OBSERVATION_ONLY
069    }
070
071    public enum EdgeOrigin {
072        SUMMARY_COMPOSED
073    }
074
075    /** One composed cross-routine edge of the isolated SHADOW side model. */
076    public static final class ComposedEdge {
077        private final Endpoint source;
078        private final Endpoint target;
079        private final String relationType;
080        private final RoutineIdentity callerIdentity;
081        private final RoutineIdentity calleeIdentity;
082        private final int callSiteOrdinal;
083        private final Set<String> observedFunctions;
084        private final RoutineSummary.Completeness calleeCompleteness;
085        private final ApplicationGate applicationGate;
086        private final String applicationDeferralReason;
087
088        public ComposedEdge(Endpoint source, Endpoint target, String relationType,
089                RoutineIdentity callerIdentity, RoutineIdentity calleeIdentity,
090                int callSiteOrdinal) {
091            this(source, target, relationType, callerIdentity, calleeIdentity,
092                    callSiteOrdinal, null, RoutineSummary.Completeness.PARTIAL);
093        }
094
095        ComposedEdge(Endpoint source, Endpoint target, String relationType,
096                RoutineIdentity callerIdentity, RoutineIdentity calleeIdentity,
097                int callSiteOrdinal, Set<String> observedFunctions,
098                RoutineSummary.Completeness calleeCompleteness) {
099            this.source = source;
100            this.target = target;
101            this.relationType = relationType;
102            this.callerIdentity = callerIdentity;
103            this.calleeIdentity = calleeIdentity;
104            this.callSiteOrdinal = callSiteOrdinal;
105            this.observedFunctions = observedFunctions == null
106                    ? Collections.<String>emptySet()
107                    : Collections.unmodifiableSet(
108                            new TreeSet<String>(observedFunctions));
109            this.calleeCompleteness = calleeCompleteness == null
110                    ? RoutineSummary.Completeness.PARTIAL : calleeCompleteness;
111            this.applicationGate = ApplicationGate.OBSERVATION_ONLY;
112            this.applicationDeferralReason =
113                    APPLICATION_DEFERRED_CALL_VALIDITY_NOT_PROVEN;
114        }
115
116        public Endpoint getSource() { return source; }
117        public Endpoint getTarget() { return target; }
118        public String getRelationType() { return relationType; }
119        /** Composing caller routine (occurrence identity component). */
120        public RoutineIdentity getCallerIdentity() { return callerIdentity; }
121        /** Composed callee routine (occurrence identity component). */
122        public RoutineIdentity getCalleeIdentity() { return calleeIdentity; }
123        /** Display rendering of the caller identity. */
124        public String getCallerName() { return String.valueOf(callerIdentity); }
125        /** Display rendering of the callee identity. */
126        public String getCalleeName() { return String.valueOf(calleeIdentity); }
127        /** Position of the composing call site in the caller's call-site
128         *  list (occurrence identity component). */
129        public int getCallSiteOrdinal() { return callSiteOrdinal; }
130        /** Unordered advisory names observed on contributing paths. */
131        public Set<String> getObservedFunctions() { return observedFunctions; }
132        /** @deprecated use {@link #getObservedFunctions()}. */
133        @Deprecated
134        public Set<String> getTransforms() { return observedFunctions; }
135        public RoutineSummary.Completeness getCalleeCompleteness() {
136            return calleeCompleteness;
137        }
138        public ApplicationGate getApplicationGate() { return applicationGate; }
139        /** Stable reason this observation is not an applicable addition. */
140        public String getApplicationDeferralReason() {
141            return applicationDeferralReason;
142        }
143        public EdgeOrigin getOrigin() { return EdgeOrigin.SUMMARY_COMPOSED; }
144
145        ComposedEdge withMetadata(Set<String> joined,
146                RoutineSummary.Completeness joinedCompleteness) {
147            return new ComposedEdge(source, target, relationType,
148                    callerIdentity, calleeIdentity, callSiteOrdinal, joined,
149                    joinedCompleteness);
150        }
151
152        /** OCCURRENCE identity (codex-B3-r2 finding 8 ruling, tightened per
153         *  r3 finding 3): FULL RoutineIdentity equality for caller and
154         *  callee — never a rendering, which omits signature fields and
155         *  collapsed distinct same-name/same-arity identities. */
156        @Override
157        public boolean equals(Object o) {
158            if (this == o) {
159                return true;
160            }
161            if (!(o instanceof ComposedEdge)) {
162                return false;
163            }
164            ComposedEdge e = (ComposedEdge) o;
165            // non-identifier-compare: relationType is a fixed enum-like token;
166            // identities compare via RoutineIdentity.equals (canonical fields)
167            return source.equals(e.source) && target.equals(e.target)
168                    && relationType.equals(e.relationType)
169                    && callerIdentity.equals(e.callerIdentity)
170                    && calleeIdentity.equals(e.calleeIdentity)
171                    && callSiteOrdinal == e.callSiteOrdinal;
172        }
173
174        @Override
175        public int hashCode() {
176            int h = (source.hashCode() * 31 + target.hashCode()) * 31
177                    + relationType.hashCode();
178            h = h * 31 + callerIdentity.hashCode();
179            h = h * 31 + calleeIdentity.hashCode();
180            h = h * 31 + callSiteOrdinal;
181            return h;
182        }
183
184        @Override
185        public String toString() {
186            return source + " -" + relationType + "-> " + target
187                    + " [SUMMARY_COMPOSED " + callerIdentity + " -> "
188                    // Keep the pre-B6 debug rendering stable. Consumers that
189                    // need the trust verdict use getApplicationGate().
190                    + calleeIdentity + "]";
191        }
192    }
193
194    /** The isolated SHADOW observation output. */
195    public static final class ShadowResult {
196        private final Set<ComposedEdge> additions;
197        private final List<String> diagnostics;
198        private final Map<RoutineIdentity, RoutineSummary> summaries;
199        private final Map<RoutineIdentity, Integer> fixedPointIterations;
200
201        ShadowResult(Set<ComposedEdge> additions, List<String> diagnostics,
202                Map<RoutineIdentity, RoutineSummary> summaries) {
203            this(additions, diagnostics, summaries,
204                    Collections.<RoutineIdentity, Integer>emptyMap());
205        }
206
207        ShadowResult(Set<ComposedEdge> additions, List<String> diagnostics,
208                Map<RoutineIdentity, RoutineSummary> summaries,
209                Map<RoutineIdentity, Integer> fixedPointIterations) {
210            this.additions = Collections.unmodifiableSet(
211                    new LinkedHashSet<ComposedEdge>(additions));
212            this.diagnostics = Collections.unmodifiableList(
213                    new ArrayList<String>(diagnostics));
214            this.summaries = Collections.unmodifiableMap(
215                    new LinkedHashMap<RoutineIdentity, RoutineSummary>(summaries));
216            this.fixedPointIterations = Collections.unmodifiableMap(
217                    new LinkedHashMap<RoutineIdentity, Integer>(
218                            fixedPointIterations));
219        }
220
221        public Set<ComposedEdge> getAdditions() { return additions; }
222        public List<String> getDiagnostics() { return diagnostics; }
223        public Map<RoutineIdentity, RoutineSummary> getSummaries() { return summaries; }
224        /** Number of synchronous fixed-point rounds run for each routine's
225         *  SCC; zero means no summary evaluation (LEGACY). */
226        public Map<RoutineIdentity, Integer> getFixedPointIterations() {
227            return fixedPointIterations;
228        }
229    }
230
231    private final EDbVendor vendor;
232    private final ProceduralLineageOptions options;
233
234    public ShadowSummaryApplier(EDbVendor vendor) {
235        this(vendor, ProceduralLineageOptions.shadow());
236    }
237
238    public ShadowSummaryApplier(EDbVendor vendor,
239            ProceduralLineageOptions options) {
240        this.vendor = vendor;
241        this.options = options == null
242                ? ProceduralLineageOptions.legacy() : options;
243    }
244
245    /**
246     * Run the full SHADOW pipeline over a multi-file unit: catalog, base
247     * summaries, call-site binding (identity-first: the resolved
248     * {@link RoutineIdentity} is the summary lookup key), composition.
249     */
250    public ShadowResult apply(List<String> unitTexts) {
251        if (options.getMode() == ProceduralLineageOptions.Mode.LEGACY) {
252            return new ShadowResult(Collections.<ComposedEdge>emptySet(),
253                    Collections.<String>emptyList(),
254                    Collections.<RoutineIdentity, RoutineSummary>emptyMap(),
255                    Collections.<RoutineIdentity, Integer>emptyMap());
256        }
257        List<String> diagnostics = new ArrayList<String>();
258        diagnostics.add(APPLICATION_DEFERRED_CALL_VALIDITY_NOT_PROVEN
259                + ": composed procedure lineage is observation-only");
260        RoutineCatalog catalog = new RoutineCatalog(vendor, unitTexts,
261                new HashMap<String, TStatementList>());
262        CatalogRoutineCallBinder binder = new CatalogRoutineCallBinder(vendor, catalog);
263        RoutineSummaryExtractor extractor = new RoutineSummaryExtractor(vendor);
264
265        // Identity-keyed summary index. Duplicate definitions of one identity
266        // stay AMBIGUOUS: they bind nothing and are removed (codex-C5).
267        Map<RoutineIdentity, RoutineSummary> summaries =
268                new LinkedHashMap<RoutineIdentity, RoutineSummary>();
269        Set<RoutineIdentity> duplicates = new LinkedHashSet<RoutineIdentity>();
270        int unitOrdinal = -1;
271        for (String unitText : unitTexts) {
272            unitOrdinal++;
273            for (RoutineSummary summary : extractor.extractAll(unitText)) {
274                RoutineIdentity id = summary.getIdentity();
275                if (id == null) {
276                    String code = summary.getCompletenessReasons().contains(
277                            "PARSE_FAILED")
278                            ? "OPAQUE_UNIT_PARSE_FAILED"
279                            : "OPAQUE_UNIT_IDENTITY_UNAVAILABLE";
280                    diagnostics.add(code + ": unit=" + unitOrdinal
281                            + " reasons=" + summary.getCompletenessReasons());
282                    continue;
283                }
284                if (summaries.containsKey(id)) {
285                    duplicates.add(id);
286                } else {
287                    summaries.put(id, summary);
288                }
289            }
290        }
291        for (RoutineIdentity dup : duplicates) {
292            summaries.remove(dup);
293            diagnostics.add("DUPLICATE_DEFINITION: " + dup);
294        }
295
296        // Caller effective database (mssql USE context) per definition.
297        Map<RoutineIdentity, String> callerDb = new HashMap<RoutineIdentity, String>();
298        for (String unitText : unitTexts) {
299            RoutineCatalog.UnitIndex index = catalog.indexOf(unitText);
300            if (index == null) {
301                continue;
302            }
303            for (RoutineCatalog.Definition def : index.definitions) {
304                if (def.identity != null) {
305                    callerDb.put(def.identity, def.db);
306                }
307            }
308        }
309
310        // Freeze every call target ONCE before fixed-point evaluation. No
311        // summary-derived type/effect information is ever fed back into the
312        // binder (§2.5 frozen-graph invariant).
313        Map<RoutineIdentity, List<SiteBinding>> frozenCalls =
314                new LinkedHashMap<RoutineIdentity, List<SiteBinding>>();
315        Map<RoutineIdentity, List<String>> localReasons =
316                new LinkedHashMap<RoutineIdentity, List<String>>();
317        for (RoutineIdentity callerId : orderedIdentities(summaries.keySet())) {
318            RoutineSummary caller = summaries.get(callerId);
319            String callerName = callerId.toString();
320            Set<String> reasons = new TreeSet<String>();
321            // The extractor's call inventory and every callee-edge projection
322            // are not yet exhaustively preflighted. Until that proof exists,
323            // no evaluated public summary can authorize replacement.
324            reasons.add("CALL_INVENTORY_NOT_PROVEN");
325            for (String reason : caller.getCompletenessReasons()) {
326                // Base extraction marks every observed call unresolved. The
327                // frozen binder verdict below replaces that provisional gate.
328                if (!"CALLS_UNRESOLVED".equals(reason)) { // non-identifier-compare: completeness reason token
329                    reasons.add(reason);
330                }
331            }
332            List<SiteBinding> resolved = new ArrayList<SiteBinding>();
333            int callSiteOrdinal = -1;
334            for (CallSiteRef site : caller.getCallSites()) {
335                callSiteOrdinal++;
336                CatalogRoutineCallBinder.BoundCall bound =
337                        binder.bind(site, callerDb.get(caller.getIdentity()),
338                                callerId);
339                RoutineCallBinder.BindingResult result = bound.getResult();
340                if (result.getStatus() != RoutineCallBinder.BindingStatus.RESOLVED) {
341                    reasons.add("CALLS_UNRESOLVED");
342                    diagnostics.add(result.getStatus() + ": "
343                            + site.getRaw().getRawName() + " in " + callerName);
344                    addBindingDiagnostics(result, diagnostics);
345                    continue;
346                }
347                boolean invalidOutTarget = hasBindingDiagnostic(result,
348                        RoutineCallBinder.BindingDiagnosticCode
349                                .PROC_CALL_OUT_TARGET_NOT_WRITABLE);
350                boolean outputModeMismatch = hasBindingDiagnostic(result,
351                        RoutineCallBinder.BindingDiagnosticCode
352                                .PROC_CALL_OUTPUT_MODE_MISMATCH);
353                if (invalidOutTarget || outputModeMismatch) {
354                    // Invalid OUT/OUTPUT binding makes the call non-executable.
355                    // Quarantine the complete site: even an otherwise
356                    // independent callee table effect cannot occur.
357                    if (invalidOutTarget) {
358                        reasons.add("PROC_CALL_OUT_TARGET_NOT_WRITABLE");
359                    }
360                    if (outputModeMismatch) {
361                        reasons.add("PROC_CALL_OUTPUT_MODE_MISMATCH");
362                    }
363                    reasons.add("CALL_BINDING_PARTIAL");
364                    addBindingDiagnostics(result, diagnostics);
365                    continue;
366                }
367                RoutineSummary callee = summaries.get(result.getResolved());
368                if (callee == null) {
369                    reasons.add("CALLEE_SUMMARY_UNAVAILABLE");
370                    diagnostics.add("CALLEE_SUMMARY_UNAVAILABLE: "
371                            + site.getRaw().getRawName() + " in " + callerName);
372                    continue;
373                }
374                if (callee.getCompleteness() == RoutineSummary.Completeness.OPAQUE) {
375                    reasons.add("CALLEE_OPAQUE");
376                    diagnostics.add("CALLEE_OPAQUE: "
377                            + site.getRaw().getRawName() + " in " + callerName);
378                    continue;
379                }
380                boolean resultProjectionProven = true;
381                if (result.getResultSets() != null) {
382                    int targetCount = result.getResultSets()
383                            .getPositionalTargets().size();
384                    List<String> arityMismatches = resultSetArityMismatches(
385                            callee, targetCount);
386                    List<String> duplicateAliases = duplicateAliasShapes(callee);
387                    List<String> unresolvedProjections =
388                            unresolvedResultSetProjections(callee);
389                    List<String> duplicateTargets =
390                            duplicateInsertTargets(result.getResultSets()
391                                    .getPositionalTargets());
392                    resultProjectionProven = arityMismatches.isEmpty()
393                            && duplicateAliases.isEmpty()
394                            && unresolvedProjections.isEmpty()
395                            && duplicateTargets.isEmpty()
396                            && !callee.getResultSetShapes().isEmpty();
397                    for (String mismatch : arityMismatches) {
398                        reasons.add("RESULT_SET_MAPPING_ARITY_MISMATCH");
399                        diagnostics.add("RESULT_SET_MAPPING_ARITY_MISMATCH: "
400                                + site.getRaw().getRawName() + " in "
401                                + callerName + " " + mismatch);
402                    }
403                    for (String shapeName : duplicateAliases) {
404                        reasons.add("RESULT_SET_MAPPING_AMBIGUOUS");
405                        diagnostics.add("RESULT_SET_MAPPING_AMBIGUOUS: "
406                                + site.getRaw().getRawName() + " in "
407                                + callerName + " shape=" + shapeName);
408                    }
409                    for (String projection : unresolvedProjections) {
410                        reasons.add("RESULT_SET_MAPPING_UNRESOLVED");
411                        diagnostics.add("RESULT_SET_MAPPING_UNRESOLVED: "
412                                + site.getRaw().getRawName() + " in "
413                                + callerName + " projection=" + projection);
414                    }
415                    for (String duplicate : duplicateTargets) {
416                        reasons.add("RESULT_SET_MAPPING_TARGET_DUPLICATE");
417                        diagnostics.add(
418                                "RESULT_SET_MAPPING_TARGET_DUPLICATE: "
419                                + site.getRaw().getRawName() + " in "
420                                + callerName + " " + duplicate);
421                    }
422                }
423                if (result.getCompleteness()
424                        == RoutineCallBinder.BindingCompleteness.PARTIAL) {
425                    reasons.add("CALL_BINDING_PARTIAL");
426                }
427                addBindingDiagnostics(result, diagnostics);
428                resolved.add(new SiteBinding(site, bound, callee,
429                        callSiteOrdinal, resultProjectionProven));
430            }
431            if (caller.getCalls().size() > caller.getCallSites().size()) {
432                reasons.add("CALLS_UNRESOLVED");
433            }
434            frozenCalls.put(callerId, resolved);
435            localReasons.put(callerId, new ArrayList<String>(reasons));
436        }
437
438        Evaluation evaluation = evaluateFixedPoint(summaries, frozenCalls,
439                localReasons, diagnostics);
440
441        // Project each frozen call against the evaluated callee. This remains
442        // an isolated observation side model; even ENHANCED never mutates the
443        // legacy dataflow object or exposes applicable additions.
444        Map<ComposedEdge, ComposedEdge> additions =
445                new LinkedHashMap<ComposedEdge, ComposedEdge>();
446        for (RoutineIdentity callerId
447                : orderedIdentities(evaluation.summaries.keySet())) {
448            List<SiteBinding> evaluatedCalls = callsWithEvaluatedCallees(
449                    frozenCalls.get(callerId), evaluation.summaries);
450            for (SiteBinding call : evaluatedCalls) {
451                composeCallSite(call.site, call.bound, call.callee, callerId,
452                        call.ordinal, call.resultProjectionProven, additions);
453            }
454            composeBridges(callerId, evaluatedCalls, additions);
455        }
456        Set<ComposedEdge> orderedAdditionSet = orderedAdditions(additions);
457        Collections.sort(diagnostics);
458        return new ShadowResult(orderedAdditionSet, diagnostics,
459                evaluation.summaries,
460                evaluation.iterations);
461    }
462
463    private List<String> duplicateAliasShapes(RoutineSummary callee) {
464        List<String> ambiguous = new ArrayList<String>();
465        for (Map.Entry<String, List<String>> entry
466                : callee.getResultSetShapes().entrySet()) {
467            List<String> shape = entry.getValue();
468            if (hasAmbiguousShapeAliases(shape)) {
469                ambiguous.add(entry.getKey());
470            }
471        }
472        Collections.sort(ambiguous);
473        return ambiguous;
474    }
475
476    private static List<String> resultSetArityMismatches(
477            RoutineSummary callee, int targetCount) {
478        List<String> mismatches = new ArrayList<String>();
479        for (Map.Entry<String, List<String>> entry
480                : callee.getResultSetShapes().entrySet()) {
481            int outputCount = entry.getValue().size();
482            if (outputCount != targetCount) {
483                mismatches.add("shape=" + entry.getKey()
484                        + " outputs=" + outputCount
485                        + " targets=" + targetCount);
486            }
487        }
488        Collections.sort(mismatches);
489        return mismatches;
490    }
491
492    private List<String> unresolvedResultSetProjections(
493            RoutineSummary callee) {
494        Set<String> unresolved = new TreeSet<String>();
495        if (callee.getResultSetShapes().isEmpty()) {
496            unresolved.add("NO_RESULT_SHAPE");
497        }
498        for (RoutineSummaryEdge edge : callee.getEdges()) {
499            Endpoint target = edge.getTarget();
500            if (target.getKind() != EndpointKind.RESULT_SET_COLUMN) {
501                continue;
502            }
503            List<String> shape = callee.getResultSetShapes().get(
504                    target.getParent());
505            // Duplicate shapes have their more specific diagnostic above.
506            if (shape != null && hasAmbiguousShapeAliases(shape)) {
507                continue;
508            }
509            if (shape == null
510                    || uniqueShapeOrdinal(shape, target.getColumn()) < 0) {
511                unresolved.add(target.getParent() + "." + target.getColumn());
512            }
513        }
514        return new ArrayList<String>(unresolved);
515    }
516
517    /** Duplicate explicit INSERT targets make INSERT...EXEC invalid in SQL
518     *  Server. Compare with the vendor identifier facade; spelling/case must
519     *  not decide whether a target list is accepted. */
520    private List<String> duplicateInsertTargets(List<TObjectName> targets) {
521        List<String> duplicates = new ArrayList<String>();
522        if (targets == null) {
523            return duplicates;
524        }
525        for (int i = 0; i < targets.size(); i++) {
526            for (int j = i + 1; j < targets.size(); j++) {
527                String left = targets.get(i).toString();
528                String right = targets.get(j).toString();
529                if (sameShapeColumn(left, right)) {
530                    duplicates.add("target=" + left + " positions="
531                            + (i + 1) + "," + (j + 1));
532                }
533            }
534        }
535        Collections.sort(duplicates);
536        return duplicates;
537    }
538
539    private static void addBindingDiagnostics(
540            RoutineCallBinder.BindingResult result, List<String> diagnostics) {
541        for (RoutineCallBinder.BindingDiagnostic d : result.getDiagnostics()) {
542            diagnostics.add(d.getCode() + ": " + d.getDetail());
543        }
544    }
545
546    private static boolean hasBindingDiagnostic(
547            RoutineCallBinder.BindingResult result,
548            RoutineCallBinder.BindingDiagnosticCode code) {
549        for (RoutineCallBinder.BindingDiagnostic diagnostic
550                : result.getDiagnostics()) {
551            if (diagnostic.getCode() == code) {
552                return true;
553            }
554        }
555        return false;
556    }
557
558    /** One RESOLVED call site with its binding and callee summary. */
559    private static final class SiteBinding {
560        final CallSiteRef site;
561        final CatalogRoutineCallBinder.BoundCall bound;
562        final RoutineSummary callee;
563        final int ordinal;
564        /** All result shapes at this call site passed cardinality, alias, and
565         *  endpoint-to-shape preflight. False suppresses every result-set
566         *  projection while preserving OUT and table-side-effect edges. */
567        final boolean resultProjectionProven;
568
569        SiteBinding(CallSiteRef site, CatalogRoutineCallBinder.BoundCall bound,
570                RoutineSummary callee, int ordinal,
571                boolean resultProjectionProven) {
572            this.site = site;
573            this.bound = bound;
574            this.callee = callee;
575            this.ordinal = ordinal;
576            this.resultProjectionProven = resultProjectionProven;
577        }
578
579        SiteBinding withCallee(RoutineSummary evaluated) {
580            return new SiteBinding(site, bound, evaluated, ordinal,
581                    resultProjectionProven);
582        }
583    }
584
585    private static final class Evaluation {
586        final Map<RoutineIdentity, RoutineSummary> summaries;
587        final Map<RoutineIdentity, Integer> iterations;
588
589        Evaluation(Map<RoutineIdentity, RoutineSummary> summaries,
590                Map<RoutineIdentity, Integer> iterations) {
591            this.summaries = summaries;
592            this.iterations = iterations;
593        }
594    }
595
596    /**
597     * B5: evaluate the frozen routine-call graph one SCC at a time, callees
598     * before callers. Within a recursive component updates are synchronous
599     * monotone joins over a finite edge/advisory-function lattice. A cap preserves
600     * the accumulated under-approximation and changes only completeness.
601     */
602    private Evaluation evaluateFixedPoint(
603            Map<RoutineIdentity, RoutineSummary> baseSummaries,
604            Map<RoutineIdentity, List<SiteBinding>> frozenCalls,
605            Map<RoutineIdentity, List<String>> localReasons,
606            List<String> diagnostics) {
607        Map<RoutineIdentity, RoutineSummary> evaluated =
608                new LinkedHashMap<RoutineIdentity, RoutineSummary>();
609        Map<RoutineIdentity, Integer> iterations =
610                new LinkedHashMap<RoutineIdentity, Integer>();
611        List<List<RoutineIdentity>> components = calleeFirstComponents(
612                baseSummaries.keySet(), frozenCalls);
613        for (List<RoutineIdentity> component : components) {
614            Set<RoutineIdentity> members =
615                    new HashSet<RoutineIdentity>(component);
616            Map<RoutineIdentity, Set<String>> reasons =
617                    componentReasons(component, members, frozenCalls,
618                            localReasons, evaluated);
619            Map<RoutineIdentity, RoutineSummary> current =
620                    new LinkedHashMap<RoutineIdentity, RoutineSummary>();
621            for (RoutineIdentity id : component) {
622                RoutineSummary base = baseSummaries.get(id);
623                current.put(id, base.evaluated(base.getEdges(),
624                        completenessOf(base, reasons.get(id)),
625                        new ArrayList<String>(reasons.get(id))));
626            }
627
628            int cap = options.iterationCapForScc(component.size());
629            int rounds = 0;
630            boolean converged = false;
631            boolean recursive = isRecursiveComponent(component, frozenCalls);
632            while (rounds < cap) {
633                rounds++;
634                Map<RoutineIdentity, RoutineSummary> next =
635                        new LinkedHashMap<RoutineIdentity, RoutineSummary>();
636                for (RoutineIdentity id : component) {
637                    RoutineSummary base = baseSummaries.get(id);
638                    if (base.getCompleteness() == RoutineSummary.Completeness.OPAQUE) {
639                        next.put(id, current.get(id));
640                        continue;
641                    }
642                    List<SiteBinding> calls = callsForEvaluation(
643                            frozenCalls.get(id), current, evaluated);
644                    Set<RoutineSummaryEdge> edges = composeSummaryEdges(
645                            base, id, calls);
646                    next.put(id, base.evaluated(edges,
647                            completenessOf(base, reasons.get(id)),
648                            new ArrayList<String>(reasons.get(id))));
649                }
650                if (sameSummaryValues(current, next)) {
651                    current = next;
652                    converged = true;
653                    break;
654                }
655                current = next;
656                // A singleton with no self-edge depends only on already-final
657                // external callees. Its first recomputation is the exact
658                // result; a second no-change probe is unnecessary and an
659                // explicit cap=1 must not fabricate a recursion diagnostic.
660                if (!recursive) {
661                    converged = true;
662                    break;
663                }
664            }
665
666            if (!converged) {
667                String componentKey = componentKey(component);
668                diagnostics.add("PROC_RECURSION_LIMIT: cap=" + cap
669                        + ", component=" + componentKey);
670                for (RoutineIdentity id : component) {
671                    RoutineSummary summary = current.get(id);
672                    Set<String> cappedReasons = new TreeSet<String>(
673                            summary.getCompletenessReasons());
674                    cappedReasons.add("PROC_RECURSION_LIMIT");
675                    current.put(id, summary.evaluated(summary.getEdges(),
676                            RoutineSummary.Completeness.PARTIAL,
677                            new ArrayList<String>(cappedReasons)));
678                }
679            }
680            for (RoutineIdentity id : component) {
681                evaluated.put(id, current.get(id));
682                iterations.put(id, Integer.valueOf(rounds));
683            }
684        }
685        // Re-key in canonical identity order so result iteration and JSON
686        // serialization are independent of file/hash-map order.
687        Map<RoutineIdentity, RoutineSummary> ordered =
688                new LinkedHashMap<RoutineIdentity, RoutineSummary>();
689        Map<RoutineIdentity, Integer> orderedIterations =
690                new LinkedHashMap<RoutineIdentity, Integer>();
691        for (RoutineIdentity id : orderedIdentities(evaluated.keySet())) {
692            RoutineSummary summary = evaluated.get(id);
693            ordered.put(id, summary);
694            orderedIterations.put(id, iterations.get(id));
695            if (summary.getCompleteness() != RoutineSummary.Completeness.COMPLETE) {
696                diagnostics.add("SUMMARY_" + summary.getCompleteness() + ": "
697                        + id.signatureKey() + " reasons="
698                        + summary.getCompletenessReasons());
699            }
700        }
701        return new Evaluation(ordered, orderedIterations);
702    }
703
704    private static boolean isRecursiveComponent(List<RoutineIdentity> component,
705            Map<RoutineIdentity, List<SiteBinding>> frozenCalls) {
706        if (component.size() > 1) {
707            return true;
708        }
709        RoutineIdentity only = component.get(0);
710        List<SiteBinding> calls = frozenCalls.get(only);
711        if (calls != null) {
712            for (SiteBinding call : calls) {
713                if (only.equals(call.bound.getResult().getResolved())) {
714                    return true;
715                }
716            }
717        }
718        return false;
719    }
720
721    private RoutineSummary.Completeness completenessOf(RoutineSummary base,
722            Set<String> reasons) {
723        if (base.getCompleteness() == RoutineSummary.Completeness.OPAQUE) {
724            return RoutineSummary.Completeness.OPAQUE;
725        }
726        return reasons == null || reasons.isEmpty()
727                ? RoutineSummary.Completeness.COMPLETE
728                : RoutineSummary.Completeness.PARTIAL;
729    }
730
731    /** Local reasons plus monotone partialness propagation through the frozen
732     *  component. Only a bounded callee marker propagates, never recursively
733     *  nested reason text. */
734    private Map<RoutineIdentity, Set<String>> componentReasons(
735            List<RoutineIdentity> component, Set<RoutineIdentity> members,
736            Map<RoutineIdentity, List<SiteBinding>> frozenCalls,
737            Map<RoutineIdentity, List<String>> localReasons,
738            Map<RoutineIdentity, RoutineSummary> evaluated) {
739        Map<RoutineIdentity, Set<String>> reasons =
740                new LinkedHashMap<RoutineIdentity, Set<String>>();
741        for (RoutineIdentity id : component) {
742            Set<String> own = new TreeSet<String>();
743            List<String> local = localReasons.get(id);
744            if (local != null) {
745                own.addAll(local);
746            }
747            List<SiteBinding> calls = frozenCalls.get(id);
748            if (calls != null) {
749                for (SiteBinding call : calls) {
750                    RoutineIdentity callee = call.bound.getResult().getResolved();
751                    if (!members.contains(callee)) {
752                        RoutineSummary done = evaluated.get(callee);
753                        if (done != null && done.getCompleteness()
754                                != RoutineSummary.Completeness.COMPLETE) {
755                            own.add("CALLEE_PARTIAL:" + callee.signatureKey());
756                        }
757                    }
758                }
759            }
760            reasons.put(id, own);
761        }
762        boolean changed;
763        do {
764            changed = false;
765            for (RoutineIdentity id : component) {
766                List<SiteBinding> calls = frozenCalls.get(id);
767                if (calls == null) {
768                    continue;
769                }
770                for (SiteBinding call : calls) {
771                    RoutineIdentity callee = call.bound.getResult().getResolved();
772                    if (members.contains(callee)
773                            && !reasons.get(callee).isEmpty()) {
774                        changed |= reasons.get(id).add(
775                                "CALLEE_PARTIAL:" + callee.signatureKey());
776                    }
777                }
778            }
779        } while (changed);
780        return reasons;
781    }
782
783    private Set<RoutineSummaryEdge> composeSummaryEdges(RoutineSummary base,
784            RoutineIdentity callerId, List<SiteBinding> calls) {
785        Map<ComposedEdge, ComposedEdge> composed =
786                new LinkedHashMap<ComposedEdge, ComposedEdge>();
787        for (SiteBinding call : calls) {
788            composeCallSite(call.site, call.bound, call.callee, callerId,
789                    call.ordinal, call.resultProjectionProven, composed);
790        }
791        composeBridges(callerId, calls, composed);
792        Map<RoutineSummaryEdge, RoutineSummaryEdge> joined =
793                new LinkedHashMap<RoutineSummaryEdge, RoutineSummaryEdge>();
794        for (RoutineSummaryEdge edge : base.getEdges()) {
795            joinSummaryEdge(joined, edge);
796        }
797        for (ComposedEdge edge : composed.values()) {
798            joinSummaryEdge(joined, new RoutineSummaryEdge(edge.getSource(),
799                    edge.getTarget(), edge.getRelationType(),
800                    edge.getObservedFunctions()));
801        }
802        List<RoutineSummaryEdge> ordered =
803                new ArrayList<RoutineSummaryEdge>(joined.values());
804        Collections.sort(ordered, SUMMARY_EDGE_ORDER);
805        return new LinkedHashSet<RoutineSummaryEdge>(ordered);
806    }
807
808    private void joinSummaryEdge(
809            Map<RoutineSummaryEdge, RoutineSummaryEdge> joined,
810            RoutineSummaryEdge candidate) {
811        RoutineSummaryEdge bounded = boundObservedFunctions(candidate);
812        RoutineSummaryEdge prior = joined.get(bounded);
813        joined.put(bounded, prior == null ? bounded
814                : prior.joinMetadata(bounded,
815                        options.getMaxObservedFunctions()));
816    }
817
818    private RoutineSummaryEdge boundObservedFunctions(
819            RoutineSummaryEdge edge) {
820        if (edge.isObservedFunctionsTop()
821                || edge.getObservedFunctions().size()
822                        <= options.getMaxObservedFunctions()) {
823            return edge;
824        }
825        return new RoutineSummaryEdge(edge.getSource(), edge.getTarget(),
826                edge.getRelationType(), Collections.singleton(
827                        RoutineSummaryEdge.OBSERVED_FUNCTIONS_TOP));
828    }
829
830    private static boolean sameSummaryValues(
831            Map<RoutineIdentity, RoutineSummary> a,
832            Map<RoutineIdentity, RoutineSummary> b) {
833        if (!a.keySet().equals(b.keySet())) {
834            return false;
835        }
836        for (RoutineIdentity id : a.keySet()) {
837            RoutineSummary left = a.get(id);
838            RoutineSummary right = b.get(id);
839            if (left.getCompleteness() != right.getCompleteness()
840                    || !left.getCompletenessReasons().equals(
841                            right.getCompletenessReasons())
842                    || !sameEdgeValues(left.getEdges(), right.getEdges())) {
843                return false;
844            }
845        }
846        return true;
847    }
848
849    private static boolean sameEdgeValues(Set<RoutineSummaryEdge> a,
850            Set<RoutineSummaryEdge> b) {
851        if (a.size() != b.size()) {
852            return false;
853        }
854        Map<RoutineSummaryEdge, Set<String>> rightValues =
855                new HashMap<RoutineSummaryEdge, Set<String>>();
856        for (RoutineSummaryEdge edge : b) {
857            rightValues.put(edge, edge.getObservedFunctions());
858        }
859        for (RoutineSummaryEdge edge : a) {
860            Set<String> right = rightValues.get(edge);
861            if (right == null
862                    || !edge.getObservedFunctions().equals(right)) {
863                return false;
864            }
865        }
866        return true;
867    }
868
869    private static List<SiteBinding> callsForEvaluation(
870            List<SiteBinding> frozen, Map<RoutineIdentity, RoutineSummary> current,
871            Map<RoutineIdentity, RoutineSummary> evaluated) {
872        List<SiteBinding> result = new ArrayList<SiteBinding>();
873        if (frozen == null) {
874            return result;
875        }
876        for (SiteBinding call : frozen) {
877            RoutineIdentity calleeId = call.bound.getResult().getResolved();
878            RoutineSummary callee = current.get(calleeId);
879            if (callee == null) {
880                callee = evaluated.get(calleeId);
881            }
882            if (callee != null) {
883                result.add(call.withCallee(callee));
884            }
885        }
886        return result;
887    }
888
889    private static List<SiteBinding> callsWithEvaluatedCallees(
890            List<SiteBinding> frozen,
891            Map<RoutineIdentity, RoutineSummary> evaluated) {
892        return callsForEvaluation(frozen,
893                Collections.<RoutineIdentity, RoutineSummary>emptyMap(), evaluated);
894    }
895
896    // ------------------------------------------------------------------
897    // Deterministic non-recursive SCC discovery and callee-first ordering.
898    // ------------------------------------------------------------------
899
900    private static final Comparator<RoutineIdentity> IDENTITY_ORDER =
901            new Comparator<RoutineIdentity>() {
902                @Override
903                public int compare(RoutineIdentity a, RoutineIdentity b) {
904                    return a.signatureKey().compareTo(b.signatureKey());
905                }
906            };
907
908    private static final Comparator<RoutineSummaryEdge> SUMMARY_EDGE_ORDER =
909            new Comparator<RoutineSummaryEdge>() {
910                @Override
911                public int compare(RoutineSummaryEdge a, RoutineSummaryEdge b) {
912                    int c = a.getSource().compareTo(b.getSource());
913                    if (c != 0) return c;
914                    c = a.getTarget().compareTo(b.getTarget());
915                    if (c != 0) return c;
916                    return a.getRelationType().compareTo(b.getRelationType());
917                }
918            };
919
920    private static List<RoutineIdentity> orderedIdentities(
921            Set<RoutineIdentity> identities) {
922        List<RoutineIdentity> ordered = new ArrayList<RoutineIdentity>(identities);
923        Collections.sort(ordered, IDENTITY_ORDER);
924        return ordered;
925    }
926
927    private static final class DfsFrame {
928        final RoutineIdentity node;
929        final List<RoutineIdentity> neighbors;
930        int next;
931
932        DfsFrame(RoutineIdentity node, List<RoutineIdentity> neighbors) {
933            this.node = node;
934            this.neighbors = neighbors;
935        }
936    }
937
938    private static List<List<RoutineIdentity>> calleeFirstComponents(
939            Set<RoutineIdentity> nodes,
940            Map<RoutineIdentity, List<SiteBinding>> calls) {
941        Map<RoutineIdentity, List<RoutineIdentity>> graph =
942                adjacency(nodes, calls, false);
943        Map<RoutineIdentity, List<RoutineIdentity>> reverse =
944                adjacency(nodes, calls, true);
945        Set<RoutineIdentity> visited = new HashSet<RoutineIdentity>();
946        List<RoutineIdentity> finish = new ArrayList<RoutineIdentity>();
947        for (RoutineIdentity start : orderedIdentities(nodes)) {
948            if (!visited.add(start)) {
949                continue;
950            }
951            Deque<DfsFrame> stack = new ArrayDeque<DfsFrame>();
952            stack.push(new DfsFrame(start, graph.get(start)));
953            while (!stack.isEmpty()) {
954                DfsFrame frame = stack.peek();
955                if (frame.next < frame.neighbors.size()) {
956                    RoutineIdentity next = frame.neighbors.get(frame.next++);
957                    if (visited.add(next)) {
958                        stack.push(new DfsFrame(next, graph.get(next)));
959                    }
960                } else {
961                    finish.add(frame.node);
962                    stack.pop();
963                }
964            }
965        }
966        List<List<RoutineIdentity>> components =
967                new ArrayList<List<RoutineIdentity>>();
968        visited.clear();
969        for (int i = finish.size() - 1; i >= 0; i--) {
970            RoutineIdentity start = finish.get(i);
971            if (!visited.add(start)) {
972                continue;
973            }
974            List<RoutineIdentity> component = new ArrayList<RoutineIdentity>();
975            Deque<RoutineIdentity> stack = new ArrayDeque<RoutineIdentity>();
976            stack.push(start);
977            while (!stack.isEmpty()) {
978                RoutineIdentity node = stack.pop();
979                component.add(node);
980                List<RoutineIdentity> neighbors = reverse.get(node);
981                for (int n = neighbors.size() - 1; n >= 0; n--) {
982                    if (visited.add(neighbors.get(n))) {
983                        stack.push(neighbors.get(n));
984                    }
985                }
986            }
987            Collections.sort(component, IDENTITY_ORDER);
988            components.add(component);
989        }
990        return orderComponentsByDependencies(components, graph);
991    }
992
993    private static Map<RoutineIdentity, List<RoutineIdentity>> adjacency(
994            Set<RoutineIdentity> nodes,
995            Map<RoutineIdentity, List<SiteBinding>> calls, boolean reverse) {
996        Map<RoutineIdentity, Set<RoutineIdentity>> sets =
997                new LinkedHashMap<RoutineIdentity, Set<RoutineIdentity>>();
998        for (RoutineIdentity id : orderedIdentities(nodes)) {
999            sets.put(id, new TreeSet<RoutineIdentity>(IDENTITY_ORDER));
1000        }
1001        for (RoutineIdentity caller : orderedIdentities(nodes)) {
1002            List<SiteBinding> sites = calls.get(caller);
1003            if (sites == null) {
1004                continue;
1005            }
1006            for (SiteBinding site : sites) {
1007                RoutineIdentity callee = site.bound.getResult().getResolved();
1008                if (!sets.containsKey(callee)) {
1009                    continue;
1010                }
1011                if (reverse) {
1012                    sets.get(callee).add(caller);
1013                } else {
1014                    sets.get(caller).add(callee);
1015                }
1016            }
1017        }
1018        Map<RoutineIdentity, List<RoutineIdentity>> result =
1019                new LinkedHashMap<RoutineIdentity, List<RoutineIdentity>>();
1020        for (RoutineIdentity id : orderedIdentities(nodes)) {
1021            result.put(id, new ArrayList<RoutineIdentity>(sets.get(id)));
1022        }
1023        return result;
1024    }
1025
1026    private static List<List<RoutineIdentity>> orderComponentsByDependencies(
1027            List<List<RoutineIdentity>> components,
1028            Map<RoutineIdentity, List<RoutineIdentity>> graph) {
1029        Map<RoutineIdentity, Integer> componentOf =
1030                new HashMap<RoutineIdentity, Integer>();
1031        for (int i = 0; i < components.size(); i++) {
1032            for (RoutineIdentity id : components.get(i)) {
1033                componentOf.put(id, Integer.valueOf(i));
1034            }
1035        }
1036        Map<Integer, Set<Integer>> dependencies =
1037                new LinkedHashMap<Integer, Set<Integer>>();
1038        for (int i = 0; i < components.size(); i++) {
1039            dependencies.put(Integer.valueOf(i), new LinkedHashSet<Integer>());
1040        }
1041        for (Map.Entry<RoutineIdentity, List<RoutineIdentity>> entry
1042                : graph.entrySet()) {
1043            int caller = componentOf.get(entry.getKey()).intValue();
1044            for (RoutineIdentity calleeId : entry.getValue()) {
1045                int callee = componentOf.get(calleeId).intValue();
1046                if (caller != callee) {
1047                    dependencies.get(Integer.valueOf(caller)).add(
1048                            Integer.valueOf(callee));
1049                }
1050            }
1051        }
1052        List<List<RoutineIdentity>> ordered =
1053                new ArrayList<List<RoutineIdentity>>();
1054        Set<Integer> emitted = new HashSet<Integer>();
1055        while (ordered.size() < components.size()) {
1056            int winner = -1;
1057            String winnerKey = null;
1058            for (int i = 0; i < components.size(); i++) {
1059                if (emitted.contains(Integer.valueOf(i))
1060                        || !emitted.containsAll(dependencies.get(Integer.valueOf(i)))) {
1061                    continue;
1062                }
1063                String key = componentKey(components.get(i));
1064                if (winner < 0 || key.compareTo(winnerKey) < 0) {
1065                    winner = i;
1066                    winnerKey = key;
1067                }
1068            }
1069            if (winner < 0) {
1070                throw new IllegalStateException("SCC condensation graph is cyclic");
1071            }
1072            emitted.add(Integer.valueOf(winner));
1073            ordered.add(components.get(winner));
1074        }
1075        return ordered;
1076    }
1077
1078    private static String componentKey(List<RoutineIdentity> component) {
1079        StringBuilder key = new StringBuilder();
1080        for (RoutineIdentity id : component) {
1081            if (key.length() > 0) {
1082                key.append(',');
1083            }
1084            key.append(id.signatureKey());
1085        }
1086        return key.toString();
1087    }
1088
1089    private static Set<ComposedEdge> orderedAdditions(
1090            Map<ComposedEdge, ComposedEdge> additions) {
1091        List<ComposedEdge> ordered =
1092                new ArrayList<ComposedEdge>(additions.values());
1093        Collections.sort(ordered, new Comparator<ComposedEdge>() {
1094            @Override
1095            public int compare(ComposedEdge a, ComposedEdge b) {
1096                int c = a.getSource().compareTo(b.getSource());
1097                if (c != 0) return c;
1098                c = a.getTarget().compareTo(b.getTarget());
1099                if (c != 0) return c;
1100                c = a.getRelationType().compareTo(b.getRelationType());
1101                if (c != 0) return c;
1102                c = IDENTITY_ORDER.compare(a.getCallerIdentity(),
1103                        b.getCallerIdentity());
1104                if (c != 0) return c;
1105                c = IDENTITY_ORDER.compare(a.getCalleeIdentity(),
1106                        b.getCalleeIdentity());
1107                if (c != 0) return c;
1108                return a.getCallSiteOrdinal() - b.getCallSiteOrdinal();
1109            }
1110        });
1111        return new LinkedHashSet<ComposedEdge>(ordered);
1112    }
1113
1114    /**
1115     * Intra-caller call-to-call bridge (codex-B3-r1 finding 5, generalized
1116     * to a FIXED POINT per codex-B3-r2 finding 5): for
1117     * {@code A(@v OUTPUT); B(@v)} the local {@code @v} is interior on both
1118     * sides, so neither call site's BOUNDARY reach carries the flow — the
1119     * node-level maps do. The inline engine's shared variable node merges
1120     * all reads/writes flow-INsensitively, so no statement-order check
1121     * belongs here, and the SAME call site may bridge to itself
1122     * ({@code P(@v, @v OUTPUT)}).
1123     *
1124     * <p>Mechanics: seed node-level FACTS (external sources written by a
1125     * callee OUT projection onto every model node the lvalue forward-
1126     * reaches), then propagate: an IN actual whose node carries facts feeds
1127     * its callee's formal-sourced edges — table/temp targets emit
1128     * additions, result-set targets map through INSERT…EXEC, and OUT-formal
1129     * targets seed NEW facts (the chain hop). Facts are monotone bit-joins
1130     * ⇒ the loop converges; the guard is a safety valve only.
1131     */
1132    private void composeBridges(RoutineIdentity callerIdentity,
1133            List<SiteBinding> resolved,
1134            Map<ComposedEdge, ComposedEdge> additions) {
1135        // model node -> external source endpoint -> flavor bits
1136        // (1 = an all-fdd chain reaches this node, 2 = an fdr-bearing one)
1137        Map<String, Map<Endpoint, Integer>> nodeFacts =
1138                new LinkedHashMap<String, Map<Endpoint, Integer>>();
1139        // GENUINE convergence, no iteration cap (codex-B3-r3 finding 1: a
1140        // sites-based cap cut long value/filter cycles short and silently
1141        // dropped fdr edges). Termination is structural: every state change
1142        // is a monotone join into a finite space — fact flavor bits per
1143        // (node, source) and the additions set — so a full sweep with no
1144        // change is reached in finitely many sweeps.
1145        boolean changed = seedOutFacts(callerIdentity, resolved, nodeFacts);
1146        while (changed) {
1147            changed = false;
1148            for (SiteBinding in : resolved) {
1149                RoutineIdentity inCallee = in.bound.getResult().getResolved();
1150                for (Map.Entry<Integer, Set<String>> inEntry
1151                        : in.site.getInNodes().entrySet()) {
1152                    int inActual = inEntry.getKey();
1153                    Map<Endpoint, Integer> facts = factsAt(nodeFacts,
1154                            inEntry.getValue());
1155                    if (facts.isEmpty()) {
1156                        continue;
1157                    }
1158                    int inFormal = formalOfActual(
1159                            in.bound.getActualPositionByFormal(), inActual);
1160                    if (inFormal < 0 || !in.bound.getResult().getInputs()
1161                            .containsKey(inFormal)) {
1162                        continue; // binder refused the input (literal, pure OUT)
1163                    }
1164                    for (RoutineSummaryEdge edge : in.callee.getEdges()) {
1165                        if (edge.getSource().getKind() != EndpointKind.FORMAL
1166                                || formalPositionOf(edge.getSource(), inCallee)
1167                                        != inFormal) {
1168                            continue;
1169                        }
1170                        boolean edgeFdr = "fdr".equals(edge.getRelationType()); // non-identifier-compare: relation type token
1171                        changed |= emitOrChain(in, edge, edgeFdr, facts,
1172                                callerIdentity, inCallee, additions, nodeFacts);
1173                    }
1174                }
1175            }
1176        }
1177    }
1178
1179    /** Seed facts: each validated OUT projection writes its callee-side
1180     *  sources onto every node the bound lvalue forward-reaches. Returns
1181     *  true when anything was seeded. */
1182    private boolean seedOutFacts(RoutineIdentity callerIdentity,
1183            List<SiteBinding> resolved,
1184            Map<String, Map<Endpoint, Integer>> nodeFacts) {
1185        boolean seeded = false;
1186        for (SiteBinding out : resolved) {
1187            RoutineIdentity outCallee = out.bound.getResult().getResolved();
1188            for (Map.Entry<Integer, RoutineCallBinder.WritableTarget> o
1189                    : out.bound.getResult().getOutputs().entrySet()) {
1190                int outFormal = o.getKey();
1191                Integer outActual = out.bound.getActualPositionByFormal().get(outFormal);
1192                if (outActual == null) {
1193                    continue;
1194                }
1195                Map<String, Integer> reachNodes =
1196                        out.site.getOutReachNodes().get(outActual);
1197                if (reachNodes == null || reachNodes.isEmpty()) {
1198                    continue;
1199                }
1200                for (RoutineSummaryEdge edge : out.callee.getEdges()) {
1201                    if (edge.getTarget().getKind() != EndpointKind.FORMAL
1202                            || formalPositionOf(edge.getTarget(), outCallee)
1203                                    != outFormal) {
1204                        continue;
1205                    }
1206                    boolean edgeFdr = "fdr".equals(edge.getRelationType()); // non-identifier-compare: relation type token
1207                    for (Attributed s : resolveCalleeSource(edge.getSource(),
1208                            outCallee, out.bound.getActualPositionByFormal(),
1209                            out.site, out.bound.getResult(), callerIdentity)) {
1210                        for (Map.Entry<String, Integer> node : reachNodes.entrySet()) {
1211                            int bits = flavorOf(s.fdr || edgeFdr, node.getValue());
1212                            seeded |= joinFact(nodeFacts, node.getKey(),
1213                                    s.endpoint, bits);
1214                        }
1215                    }
1216                }
1217            }
1218        }
1219        return seeded;
1220    }
1221
1222    /** Emit additions for one fact-fed callee edge, or seed chained facts
1223     *  when the edge lands on a further OUT projection. */
1224    private boolean emitOrChain(SiteBinding in, RoutineSummaryEdge edge,
1225            boolean edgeFdr, Map<Endpoint, Integer> facts,
1226            RoutineIdentity callerIdentity, RoutineIdentity inCallee,
1227            Map<ComposedEdge, ComposedEdge> additions,
1228            Map<String, Map<Endpoint, Integer>> nodeFacts) {
1229        boolean changed = false;
1230        Endpoint target = edge.getTarget();
1231        if (target.getKind() == EndpointKind.TABLE_COLUMN
1232                || target.getKind() == EndpointKind.TEMP_COLUMN
1233                || target.getKind() == EndpointKind.RESULT_SET_COLUMN) {
1234            List<Attributed> resolvedTargets = resolveCalleeTarget(target,
1235                    inCallee, in.bound.getActualPositionByFormal(), in.site,
1236                    in.bound, in.callee, in.resultProjectionProven);
1237            for (Attributed t : resolvedTargets) {
1238                for (Map.Entry<Endpoint, Integer> fact : facts.entrySet()) {
1239                    boolean legsFdd = !edgeFdr && !t.fdr;
1240                    if (legsFdd && (fact.getValue() & 1) != 0) {
1241                        changed |= addComposedEdge(additions, fact.getKey(),
1242                                t.endpoint, "fdd", callerIdentity, inCallee,
1243                                in.ordinal, edge.getObservedFunctions(),
1244                                // A node bridge may already contain effects
1245                                // from another callee whose completeness is
1246                                // not represented in the legacy bitset. Keep
1247                                // the gate conservative until bridge facts
1248                                // carry full provenance.
1249                                RoutineSummary.Completeness.PARTIAL);
1250                    }
1251                    if (!legsFdd || (fact.getValue() & 2) != 0) {
1252                        changed |= addComposedEdge(additions, fact.getKey(),
1253                                t.endpoint, "fdr", callerIdentity, inCallee,
1254                                in.ordinal, edge.getObservedFunctions(),
1255                                RoutineSummary.Completeness.PARTIAL);
1256                    }
1257                }
1258            }
1259            return changed;
1260        }
1261        if (target.getKind() == EndpointKind.FORMAL) {
1262            // chain hop: IN formal -> OUT formal of the same callee, whose
1263            // lvalue seeds facts further downstream
1264            int chainedFormal = formalPositionOf(target, inCallee);
1265            if (chainedFormal < 0 || !in.bound.getResult().getOutputs()
1266                    .containsKey(chainedFormal)) {
1267                return false;
1268            }
1269            Integer chainedActual = in.bound.getActualPositionByFormal()
1270                    .get(chainedFormal);
1271            if (chainedActual == null) {
1272                return false;
1273            }
1274            Map<String, Integer> reachNodes =
1275                    in.site.getOutReachNodes().get(chainedActual);
1276            if (reachNodes == null) {
1277                return false;
1278            }
1279            for (Map.Entry<Endpoint, Integer> fact : facts.entrySet()) {
1280                for (Map.Entry<String, Integer> node : reachNodes.entrySet()) {
1281                    boolean fdrChain = edgeFdr || (fact.getValue() & 1) == 0;
1282                    int bits = flavorOf(fdrChain, node.getValue());
1283                    // an fdr-bearing incoming fact also carries fdr onward
1284                    if ((fact.getValue() & 2) != 0) {
1285                        bits |= 2;
1286                    }
1287                    changed |= joinFact(nodeFacts, node.getKey(),
1288                            fact.getKey(), bits);
1289                }
1290            }
1291        }
1292        return changed;
1293    }
1294
1295    /** Compose one leg's fdr-ness with a bridge segment's flavor bits. */
1296    private static int flavorOf(boolean legFdr, int pathBits) {
1297        int bits = 0;
1298        if (!legFdr && (pathBits & 1) != 0) {
1299            bits |= 1;
1300        }
1301        if (legFdr || (pathBits & 2) != 0) {
1302            bits |= 2;
1303        }
1304        return bits;
1305    }
1306
1307    private static boolean joinFact(Map<String, Map<Endpoint, Integer>> nodeFacts,
1308            String node, Endpoint source, int bits) {
1309        if (bits == 0) {
1310            return false;
1311        }
1312        Map<Endpoint, Integer> facts = nodeFacts.get(node);
1313        if (facts == null) {
1314            facts = new LinkedHashMap<Endpoint, Integer>();
1315            nodeFacts.put(node, facts);
1316        }
1317        Integer prior = facts.get(source);
1318        int joined = prior == null ? bits : (prior | bits);
1319        if (prior != null && joined == prior) {
1320            return false;
1321        }
1322        facts.put(source, joined);
1323        return true;
1324    }
1325
1326    /** Union of facts across an actual's own model nodes. */
1327    private static Map<Endpoint, Integer> factsAt(
1328            Map<String, Map<Endpoint, Integer>> nodeFacts, Set<String> nodes) {
1329        Map<Endpoint, Integer> union = new LinkedHashMap<Endpoint, Integer>();
1330        for (String node : nodes) {
1331            Map<Endpoint, Integer> facts = nodeFacts.get(node);
1332            if (facts == null) {
1333                continue;
1334            }
1335            for (Map.Entry<Endpoint, Integer> e : facts.entrySet()) {
1336                Integer prior = union.get(e.getKey());
1337                union.put(e.getKey(), prior == null
1338                        ? e.getValue() : (prior | e.getValue()));
1339            }
1340        }
1341        return union;
1342    }
1343
1344    /** Formal position bound to a given actual position; -1 when none. */
1345    private static int formalOfActual(Map<Integer, Integer> actualByFormal,
1346            int actualPos) {
1347        for (Map.Entry<Integer, Integer> e : actualByFormal.entrySet()) {
1348            if (e.getValue() == actualPos) {
1349                return e.getKey();
1350            }
1351        }
1352        return -1;
1353    }
1354
1355    /** Compose one RESOLVED call site: callee summary edges × caller reach. */
1356    private void composeCallSite(CallSiteRef site,
1357            CatalogRoutineCallBinder.BoundCall bound, RoutineSummary callee,
1358            RoutineIdentity callerIdentity, int callSiteOrdinal,
1359            boolean resultProjectionProven,
1360            Map<ComposedEdge, ComposedEdge> additions) {
1361        RoutineIdentity calleeId = bound.getResult().getResolved();
1362        // Public B6 is observation-only until call validity, type/mutability,
1363        // context, inventory, and every projection shape are exhaustively
1364        // preflighted. Completeness cannot authorize application here.
1365        RoutineSummary.Completeness effectiveCompleteness =
1366                RoutineSummary.Completeness.PARTIAL;
1367        Map<Integer, Integer> actualByFormal = bound.getActualPositionByFormal();
1368        for (RoutineSummaryEdge edge : callee.getEdges()) {
1369            boolean edgeFdr = "fdr".equals(edge.getRelationType()); // non-identifier-compare: relation type token
1370            // Resolve the callee edge's SOURCE into caller space.
1371            List<Attributed> sources = resolveCalleeSource(edge.getSource(),
1372                    calleeId, actualByFormal, site, bound.getResult(),
1373                    callerIdentity);
1374            if (sources.isEmpty()) {
1375                continue;
1376            }
1377            // Resolve the callee edge's TARGET into caller space.
1378            List<Attributed> targets = resolveCalleeTarget(edge.getTarget(),
1379                    calleeId, actualByFormal, site, bound, callee,
1380                    resultProjectionProven);
1381
1382            for (Attributed s : sources) {
1383                for (Attributed t : targets) {
1384                    boolean anyFdr = edgeFdr || s.fdr || t.fdr;
1385                    boolean allFdd = !edgeFdr && !s.fdr && !t.fdr;
1386                    if (allFdd) {
1387                        addComposedEdge(additions, s.endpoint, t.endpoint,
1388                                "fdd", callerIdentity, calleeId, callSiteOrdinal,
1389                                edge.getObservedFunctions(), effectiveCompleteness);
1390                    } else if (anyFdr) {
1391                        addComposedEdge(additions, s.endpoint, t.endpoint,
1392                                "fdr", callerIdentity, calleeId, callSiteOrdinal,
1393                                edge.getObservedFunctions(), effectiveCompleteness);
1394                    }
1395                }
1396            }
1397        }
1398    }
1399
1400    /** Add one occurrence edge, joining bounded observed-function metadata when the
1401     *  same occurrence is reached through more than one summary path. */
1402    private boolean addComposedEdge(
1403            Map<ComposedEdge, ComposedEdge> additions,
1404            Endpoint source, Endpoint target, String relationType,
1405            RoutineIdentity callerIdentity, RoutineIdentity calleeIdentity,
1406            int callSiteOrdinal, Set<String> observedFunctions,
1407            RoutineSummary.Completeness completeness) {
1408        ComposedEdge candidate = new ComposedEdge(source, target, relationType,
1409                callerIdentity, calleeIdentity, callSiteOrdinal,
1410                joinObservedFunctions(Collections.<String>emptySet(),
1411                        observedFunctions),
1412                completeness);
1413        ComposedEdge prior = additions.get(candidate);
1414        if (prior == null) {
1415            additions.put(candidate, candidate);
1416            return true;
1417        }
1418        Set<String> joined = joinObservedFunctions(
1419                prior.getObservedFunctions(), observedFunctions);
1420        RoutineSummary.Completeness joinedCompleteness =
1421                prior.getCalleeCompleteness() == RoutineSummary.Completeness.COMPLETE
1422                        && completeness == RoutineSummary.Completeness.COMPLETE
1423                        ? RoutineSummary.Completeness.COMPLETE
1424                        : RoutineSummary.Completeness.PARTIAL;
1425        if (joined.equals(prior.getObservedFunctions())
1426                && joinedCompleteness == prior.getCalleeCompleteness()) {
1427            return false;
1428        }
1429        additions.put(candidate,
1430                prior.withMetadata(joined, joinedCompleteness));
1431        return true;
1432    }
1433
1434    private Set<String> joinObservedFunctions(Set<String> left,
1435            Set<String> right) {
1436        if (left.contains(RoutineSummaryEdge.OBSERVED_FUNCTIONS_TOP)
1437                || (right != null
1438                        && right.contains(
1439                                RoutineSummaryEdge.OBSERVED_FUNCTIONS_TOP))) {
1440            return Collections.singleton(
1441                    RoutineSummaryEdge.OBSERVED_FUNCTIONS_TOP);
1442        }
1443        Set<String> joined = new TreeSet<String>(left);
1444        if (right != null) {
1445            joined.addAll(right);
1446        }
1447        if (joined.size() > options.getMaxObservedFunctions()) {
1448            return Collections.singleton(
1449                    RoutineSummaryEdge.OBSERVED_FUNCTIONS_TOP);
1450        }
1451        return joined;
1452    }
1453
1454    /** A caller-space endpoint plus the relation flavor of the leg that
1455     *  produced it (fdr=true when the leg passed through an fdr hop). */
1456    private static final class Attributed {
1457        final Endpoint endpoint;
1458        final boolean fdr;
1459
1460        Attributed(Endpoint endpoint, boolean fdr) {
1461            this.endpoint = endpoint;
1462            this.fdr = fdr;
1463        }
1464    }
1465
1466    /**
1467     * Callee edge SOURCE in caller space: a formal chains through the caller
1468     * sources of its bound actual; global endpoints (tables, temps) pass
1469     * through unchanged. Result-set sources never compose (a callee cannot
1470     * read its own output shape).
1471     */
1472    private List<Attributed> resolveCalleeSource(Endpoint source,
1473            RoutineIdentity calleeId, Map<Integer, Integer> actualByFormal,
1474            CallSiteRef site, RoutineCallBinder.BindingResult binding,
1475            RoutineIdentity callerId) {
1476        List<Attributed> result = new ArrayList<Attributed>();
1477        if (source.getKind() == EndpointKind.FORMAL) {
1478            int formal = formalPositionOf(source, calleeId);
1479            Integer actualPos = formal < 0 ? null : actualByFormal.get(formal);
1480            if (actualPos == null) {
1481                return result; // defaulted formal: no caller-side source
1482            }
1483            // The binder's INPUT map is the authority: a formal it refused
1484            // to bind (Oracle pure OUT, literal) contributes no IN leg —
1485            // composing the raw reach anyway would fabricate flow
1486            // (codex-B3-r2 finding 3).
1487            if (!binding.getInputs().containsKey(formal)) {
1488                return result;
1489            }
1490            addAll(result, site.getInSourcesFdd().get(actualPos), false);
1491            addAll(result, site.getInSourcesFdr().get(actualPos), true);
1492            if (result.isEmpty()) {
1493                Endpoint callerFormal = callerFormalEndpoint(site, actualPos,
1494                        callerId);
1495                if (callerFormal != null) {
1496                    result.add(new Attributed(callerFormal, false));
1497                }
1498            }
1499            return result;
1500        }
1501        if (source.getKind() == EndpointKind.TABLE_COLUMN
1502                || source.getKind() == EndpointKind.TEMP_COLUMN) {
1503            result.add(new Attributed(source, false));
1504        }
1505        return result;
1506    }
1507
1508    /**
1509     * Identity-backed fallback for a bare actual that is itself one of the
1510     * caller's formals. Some dlineage models contain two variable nodes for
1511     * that spelling and the call-site reach collector can select the interior
1512     * one, leaving its boundary map empty. The declaration identity proves
1513     * this exact formal endpoint; no heuristic first-variable link is used.
1514     */
1515    private Endpoint callerFormalEndpoint(CallSiteRef site, int actualPos,
1516            RoutineIdentity callerId) {
1517        if (callerId == null || actualPos < 0
1518                || actualPos >= site.getActuals().size()) {
1519            return null;
1520        }
1521        CallSiteRef.Actual actual = site.getActuals().get(actualPos);
1522        if (actual.getBareReference() == null) {
1523            return null;
1524        }
1525        String actualName = actual.getBareReference().toString();
1526        List<String> segments = gudusoft.gsqlparser.util.SQLUtil.parseNames(actualName);
1527        // This fallback has no symbol identity, so it is safe only for an
1528        // unqualified spelling. A record field such as rec.p_in must use the
1529        // model reach collected for that exact object; suffix-matching it to
1530        // formal p_in would fabricate a caller-formal source.
1531        if (segments.size() != 1) {
1532            return null;
1533        }
1534        actualName = segments.get(0);
1535        for (String declared : callerId.getRawParamNames()) {
1536            String actualCompare = actualName.startsWith("@")
1537                    ? actualName.substring(1) : actualName;
1538            String declaredCompare = declared.startsWith("@")
1539                    ? declared.substring(1) : declared;
1540            if (gudusoft.gsqlparser.util.SQLUtil.sameName(vendor,
1541                    gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotColumn,
1542                    actualCompare, declaredCompare)) {
1543                return new Endpoint(EndpointKind.FORMAL, declared, declared);
1544            }
1545        }
1546        return null;
1547    }
1548
1549    /**
1550     * Callee edge TARGET in caller space: an OUT formal projects onto the
1551     * caller boundary reachable from its bound lvalue; a result-set column
1552     * maps positionally onto INSERT…EXEC targets; global endpoints pass
1553     * through unchanged.
1554     */
1555    private List<Attributed> resolveCalleeTarget(Endpoint target,
1556            RoutineIdentity calleeId, Map<Integer, Integer> actualByFormal,
1557            CallSiteRef site, CatalogRoutineCallBinder.BoundCall bound,
1558            RoutineSummary callee, boolean resultProjectionProven) {
1559        List<Attributed> result = new ArrayList<Attributed>();
1560        if (target.getKind() == EndpointKind.FORMAL) {
1561            int formal = formalPositionOf(target, calleeId);
1562            if (formal < 0
1563                    || !bound.getResult().getOutputs().containsKey(formal)) {
1564                return result; // not a validated OUT projection — nothing composes
1565            }
1566            Integer actualPos = actualByFormal.get(formal);
1567            if (actualPos == null) {
1568                return result;
1569            }
1570            addAll(result, site.getOutTargetsFdd().get(actualPos), false);
1571            addAll(result, site.getOutTargetsFdr().get(actualPos), true);
1572            return result;
1573        }
1574        if (target.getKind() == EndpointKind.RESULT_SET_COLUMN) {
1575            if (!resultProjectionProven) {
1576                return result;
1577            }
1578            if (site.getInsertTargets() == null) {
1579                return result;
1580            }
1581            List<String> shape = callee.getResultSetShapes().get(target.getParent());
1582            if (shape == null) {
1583                return result;
1584            }
1585            // SQL Server rejects INSERT...EXEC when result and target
1586            // cardinalities differ. Mapping a matching prefix would publish
1587            // lineage for a statement that cannot produce that write.
1588            if (shape.size() != site.getInsertTargets().size()) {
1589                return result;
1590            }
1591            int ordinal = uniqueShapeOrdinal(shape, target.getColumn());
1592            if (ordinal < 0 || ordinal >= site.getInsertTargets().size()) {
1593                return result;
1594            }
1595            result.add(new Attributed(site.getInsertTargets().get(ordinal), false));
1596            return result;
1597        }
1598        if (target.getKind() == EndpointKind.TABLE_COLUMN
1599                || target.getKind() == EndpointKind.TEMP_COLUMN) {
1600            result.add(new Attributed(target, false));
1601        }
1602        return result;
1603    }
1604
1605    /** Exact positional lookup only when the output alias occurs once under
1606     *  the vendor's identifier equality. Zero or duplicate matches are
1607     *  deferred; selecting the first duplicate would fabricate an ordinal. */
1608    private int uniqueShapeOrdinal(List<String> shape, String columnName) {
1609        if (hasAmbiguousShapeAliases(shape)) {
1610            return -1;
1611        }
1612        int found = -1;
1613        for (int i = 0; i < shape.size(); i++) {
1614            if (gudusoft.gsqlparser.util.SQLUtil.sameName(vendor,
1615                    gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotColumn,
1616                    shape.get(i), columnName)) {
1617                if (found >= 0) {
1618                    return -1;
1619                }
1620                found = i;
1621            }
1622        }
1623        return found;
1624    }
1625
1626    /** dlineage occurrence-disambiguates duplicate result aliases as
1627     *  {@code name(1)}, {@code name(2)}, ... . Treat both literal duplicates
1628     *  and that generated suffix form as ambiguous. A conservative false
1629     *  deferral of a deliberately parenthesized alias is safer than selecting
1630     *  a fabricated ordinal. */
1631    private boolean hasAmbiguousShapeAliases(List<String> shape) {
1632        for (int i = 0; i < shape.size(); i++) {
1633            for (int j = i + 1; j < shape.size(); j++) {
1634                if (sameShapeColumn(shape.get(i), shape.get(j))) {
1635                    return true;
1636                }
1637            }
1638            String base = syntheticDuplicateBase(shape.get(i));
1639            if (base != null) {
1640                for (int j = 0; j < shape.size(); j++) {
1641                    if (j != i && sameShapeColumn(base, shape.get(j))) {
1642                        return true;
1643                    }
1644                }
1645            }
1646        }
1647        return false;
1648    }
1649
1650    private boolean sameShapeColumn(String a, String b) {
1651        return gudusoft.gsqlparser.util.SQLUtil.sameName(vendor,
1652                gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotColumn, a, b);
1653    }
1654
1655    private static String syntheticDuplicateBase(String name) {
1656        if (name == null || !name.endsWith(")")) { // non-identifier-compare: generated display suffix punctuation
1657            return null;
1658        }
1659        int open = name.lastIndexOf('(');
1660        if (open <= 0 || open == name.length() - 2) {
1661            return null;
1662        }
1663        for (int i = open + 1; i < name.length() - 1; i++) {
1664            if (!Character.isDigit(name.charAt(i))) {
1665                return null;
1666            }
1667        }
1668        return name.substring(0, open);
1669    }
1670
1671    private static void addAll(List<Attributed> result, Set<Endpoint> endpoints,
1672            boolean fdr) {
1673        if (endpoints == null) {
1674            return;
1675        }
1676        for (Endpoint e : endpoints) {
1677            result.add(new Attributed(e, fdr));
1678        }
1679    }
1680
1681    /** Declaration position of a callee formal endpoint, matched over the
1682     *  identity's RAW declared spellings via the equality facade —
1683     *  re-keying stored canonical text double-folds quoted names
1684     *  (codex-B3-r2 finding 12). */
1685    private int formalPositionOf(Endpoint formal, RoutineIdentity calleeId) {
1686        String name = formal.getColumn().length() > 0
1687                ? formal.getColumn() : formal.getParent();
1688        // Endpoint parents can carry USE-qualification — match last segment.
1689        List<String> segments = gudusoft.gsqlparser.util.SQLUtil.parseNames(name);
1690        if (!segments.isEmpty()) {
1691            name = segments.get(segments.size() - 1);
1692        }
1693        if (name.startsWith("@")) {
1694            name = name.substring(1);
1695        }
1696        List<String> rawNames = calleeId.getRawParamNames();
1697        for (int i = 0; i < rawNames.size(); i++) {
1698            String declared = rawNames.get(i);
1699            if (declared.startsWith("@")) {
1700                declared = declared.substring(1);
1701            }
1702            if (gudusoft.gsqlparser.util.SQLUtil.sameName(vendor,
1703                    gudusoft.gsqlparser.sqlenv.ESQLDataObjectType.dotColumn,
1704                    name, declared)) {
1705                return i;
1706            }
1707        }
1708        return -1;
1709    }
1710}