001package gudusoft.gsqlparser.dlineage.dynamicsql;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.TCustomSqlStatement;
005import gudusoft.gsqlparser.nodes.TObjectName;
006import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
007import gudusoft.gsqlparser.util.SQLUtil;
008
009import java.util.ArrayList;
010import java.util.Collections;
011import java.util.LinkedHashMap;
012import java.util.List;
013import java.util.Map;
014
015/**
016 * B3 (design {@code routine-summary-scc-design.md} §2.1b/§2.3): the catalog-
017 * backed {@link RoutineCallBinder}. Resolution is a function over the
018 * candidate DEFINITION set — never identity field-equality against a single
019 * key — and the binding maps follow the inline engine's semantics exactly
020 * (arch-F8: SHADOW must not diff):
021 *
022 * <ul>
023 * <li>expression actual ⇒ fan-in of all leaf source references + the
024 *     expression text as transform carrier;</li>
025 * <li>literal actual ⇒ no source (omitted from the inputs map);</li>
026 * <li>OUT/INOUT target must be a bare writable lvalue — an expression,
027 *     literal, or caller pure-IN formal is diagnosed with
028 *     {@code PROC_CALL_OUT_TARGET_NOT_WRITABLE}; summary application then
029 *     quarantines the whole non-executable call;</li>
030 * <li>no unique candidate ⇒ AMBIGUOUS with the candidate list, nothing
031 *     bound (codex-C5: duplicate definitions of one identity never
032 *     overwrite each other).</li>
033 * </ul>
034 *
035 * <p>Overload resolution in B3 is the structural subset of §2.1b: named /
036 * positional binding plus default-fill over the candidate set. The vendor
037 * type-family ranking (§2.1b step 2) arrives with the SCC slice — until
038 * then two same-name candidates that both survive arity/name binding are
039 * AMBIGUOUS, which is the conservative direction.
040 */
041public final class CatalogRoutineCallBinder implements RoutineCallBinder {
042
043    /** One resolvable candidate: identity + defining unit provenance. */
044    static final class Candidate {
045        final RoutineCatalog.Definition definition;
046        final String unitText;
047
048        Candidate(RoutineCatalog.Definition definition, String unitText) {
049            this.definition = definition;
050            this.unitText = unitText;
051        }
052    }
053
054    /**
055     * Binder output enriched with what the SHADOW composition step needs
056     * beyond the B0 contract: the formal-position → actual-position map
057     * (named notation makes the two diverge) and the winning candidate's
058     * defining unit.
059     */
060    public static final class BoundCall {
061        private final BindingResult result;
062        private final Map<Integer, Integer> actualPositionByFormal;
063        private final String calleeUnitText;
064
065        BoundCall(BindingResult result, Map<Integer, Integer> actualPositionByFormal,
066                String calleeUnitText) {
067            this.result = result;
068            this.actualPositionByFormal = actualPositionByFormal == null
069                    ? Collections.<Integer, Integer>emptyMap()
070                    : Collections.unmodifiableMap(actualPositionByFormal);
071            this.calleeUnitText = calleeUnitText;
072        }
073
074        public BindingResult getResult() { return result; }
075        /** Declaration position of a formal → call-site position of its actual. */
076        public Map<Integer, Integer> getActualPositionByFormal() {
077            return actualPositionByFormal;
078        }
079        /** Unit text defining the resolved callee (identity-first lookups key
080         *  summaries by identity; this is provenance for diagnostics). */
081        public String getCalleeUnitText() { return calleeUnitText; }
082    }
083
084    private final EDbVendor vendor;
085    private final RoutineCatalog catalog;
086
087    public CatalogRoutineCallBinder(EDbVendor vendor, RoutineCatalog catalog) {
088        this.vendor = vendor;
089        this.catalog = catalog;
090    }
091
092    /** B0 contract entry: bind a raw call statement. The extractor-produced
093     *  {@link CallSiteRef} path below is the primary consumer; this entry
094     *  serves callers that hold only the AST. */
095    @Override
096    public BindingResult bind(TCustomSqlStatement callSite, CallerContext callerContext) {
097        RoutineSummaryExtractor extractor = new RoutineSummaryExtractor(vendor);
098        // A nested INSERT…EXEC execute node must not lose its consuming
099        // INSERT (codex-B3-r3 finding 4): reparse the ENCLOSING statement so
100        // the column list / missing-list flag / positional mapping survive.
101        TCustomSqlStatement toParse = callSite;
102        if (callSite instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlExecute
103                && callSite.getParentStmt()
104                        instanceof gudusoft.gsqlparser.stmt.TInsertSqlStatement) {
105            gudusoft.gsqlparser.stmt.TInsertSqlStatement parent =
106                    (gudusoft.gsqlparser.stmt.TInsertSqlStatement)
107                            callSite.getParentStmt();
108            if (parent.getExecuteStmt() != null) {
109                toParse = parent;
110            }
111        }
112        CallSiteRef ref = null;
113        List<CallSiteRef> sites = extractor.callSitesOfStatementText(
114                toParse.toString());
115        if (!sites.isEmpty()) {
116            ref = sites.get(0);
117        }
118        if (ref == null) {
119            return new BindingResult(BindingStatus.UNRESOLVED, null, null, null,
120                    null, null, null, BindingCompleteness.PARTIAL,
121                    Collections.singletonList(new BindingDiagnostic(
122                            BindingDiagnosticCode.PROC_CALL_UNRESOLVED,
123                            "unsupported call statement shape")));
124        }
125        return bind(ref, callerContext == null ? null : callerContext.getDatabase())
126                .getResult();
127    }
128
129    /**
130     * Bind one extractor-produced call site against every definition the
131     * catalog knows, under the caller's effective database context.
132     */
133    public BoundCall bind(CallSiteRef site, String callerDb) {
134        List<BindingDiagnostic> diagnostics = new ArrayList<BindingDiagnostic>();
135        List<Candidate> candidates = candidatesFor(site.getRaw().getRawName(),
136                callerDb, site.getActuals());
137        if (candidates.isEmpty()) {
138            diagnostics.add(new BindingDiagnostic(
139                    BindingDiagnosticCode.PROC_CALL_UNRESOLVED,
140                    site.getRaw().getRawName()));
141            return new BoundCall(new BindingResult(BindingStatus.UNRESOLVED, null,
142                    null, null, null, null, null, BindingCompleteness.PARTIAL,
143                    diagnostics), null, null);
144        }
145        if (candidates.size() > 1) {
146            List<RoutineIdentity> ids = new ArrayList<RoutineIdentity>();
147            for (Candidate c : candidates) {
148                ids.add(c.definition.identity);
149            }
150            diagnostics.add(new BindingDiagnostic(
151                    BindingDiagnosticCode.PROC_CALL_AMBIGUOUS_OVERLOAD,
152                    site.getRaw().getRawName() + ": " + ids.size() + " candidates"));
153            return new BoundCall(new BindingResult(BindingStatus.AMBIGUOUS, null,
154                    ids, null, null, null, null, BindingCompleteness.PARTIAL,
155                    diagnostics), null, null);
156        }
157        Candidate winner = candidates.get(0);
158        RoutineIdentity identity = winner.definition.identity;
159        Map<Integer, Integer> actualByFormal =
160                mapActualsToFormals(site.getActuals(), identity);
161        if (actualByFormal == null) {
162            // structurally compatible earlier, but named binding failed —
163            // treat as unresolved rather than guessing positions
164            diagnostics.add(new BindingDiagnostic(
165                    BindingDiagnosticCode.PROC_CALL_ARGUMENT_UNBOUND,
166                    site.getRaw().getRawName() + ": named argument binding failed"));
167            return new BoundCall(new BindingResult(BindingStatus.UNRESOLVED, null,
168                    null, null, null, null, null, BindingCompleteness.PARTIAL,
169                    diagnostics), null, null);
170        }
171        boolean partial = false;
172        Map<Integer, ActualSources> inputs = new LinkedHashMap<Integer, ActualSources>();
173        Map<Integer, WritableTarget> outputs = new LinkedHashMap<Integer, WritableTarget>();
174        for (Map.Entry<Integer, Integer> e : actualByFormal.entrySet()) {
175            int formalPos = e.getKey();
176            CallSiteRef.Actual actual = site.getActuals().get(e.getValue());
177            if (actual.isDefaultToken()) {
178                continue; // declared default applies — no source, no target
179            }
180            // Oracle pure OUT formals receive NO input value (the callee
181            // initializes them) — binding the actual's prior value would
182            // fabricate flow (codex-B3-r2 finding 3). T-SQL OUTPUT and
183            // INOUT stay input-capable.
184            boolean pureOracleOut = vendor == EDbVendor.dbvoracle
185                    && isPureOutMode(identity, formalPos);
186            if (!actual.isLiteral() && !pureOracleOut) {
187                List<TObjectName> leafs = actual.getBareReference() != null
188                        ? Collections.singletonList(actual.getBareReference())
189                        : actual.getLeafReferences();
190                if (!leafs.isEmpty() || actual.getExpressionText() != null) {
191                    inputs.put(formalPos, new ActualSources(
192                            new ArrayList<TObjectName>(leafs),
193                            actual.getExpressionText()));
194                }
195            }
196            if (declaredOut(identity, formalPos)) {
197                // T-SQL projects OUT only when the CALL SITE says OUTPUT too;
198                // omitting it passes by value (codex-B3-r1 finding 3). PL/SQL
199                // has no call-site marker — the declared mode alone decides.
200                boolean callSiteWantsOut = vendor == EDbVendor.dbvoracle
201                        || actual.isOutFlagged();
202                if (!callSiteWantsOut) {
203                    // no OUT projection, no diagnostic: passing a value into
204                    // an OUTPUT-capable formal without OUTPUT is legal T-SQL
205                } else if (actual.getBareReference() != null) {
206                    outputs.put(formalPos,
207                            new WritableTarget(actual.getBareReference()));
208                } else {
209                    // expression OR literal in an OUT position: dropped with
210                    // the required diagnostic, never fabricated.
211                    diagnostics.add(new BindingDiagnostic(
212                            BindingDiagnosticCode.PROC_CALL_OUT_TARGET_NOT_WRITABLE,
213                            "formal #" + formalPos + " of "
214                                    + site.getRaw().getRawName()));
215                    partial = true;
216                }
217            } else if (actual.isOutFlagged()) {
218                diagnostics.add(new BindingDiagnostic(
219                        BindingDiagnosticCode.PROC_CALL_OUTPUT_MODE_MISMATCH,
220                        "call-site OUTPUT flag on non-OUT formal #" + formalPos
221                                + " of " + site.getRaw().getRawName()));
222                partial = true;
223            }
224        }
225        ResultSetMapping resultSets = null;
226        if (!site.getInsertColumnNames().isEmpty()) {
227            resultSets = new ResultSetMapping(
228                    new ArrayList<TObjectName>(site.getInsertColumnNames()));
229        } else if (site.isInsertWithoutColumnList()) {
230            // INSERT…EXEC without an explicit column list: positional
231            // ordinals are unknowable without a catalog — reported, never
232            // guessed (codex-B3-r1 finding 4).
233            diagnostics.add(new BindingDiagnostic(
234                    BindingDiagnosticCode.PROC_CALL_ARGUMENT_UNBOUND,
235                    "INSERT...EXEC without column list consuming "
236                            + site.getRaw().getRawName()));
237            partial = true;
238        }
239        // @ret = EXEC p: the status capture is a validated writable lvalue.
240        // Its VALUE lineage stays deferred (RETURN_ENDPOINT_DEFERRED keeps
241        // callee summaries PARTIAL), so binding it never fabricates edges.
242        WritableTarget returnTarget = site.getReturnStatusTarget() == null
243                ? null : new WritableTarget(site.getReturnStatusTarget());
244        return new BoundCall(new BindingResult(BindingStatus.RESOLVED, identity,
245                null, inputs, outputs, returnTarget, resultSets,
246                partial ? BindingCompleteness.PARTIAL : BindingCompleteness.COMPLETE,
247                diagnostics), actualByFormal, winner.unitText);
248    }
249
250    /**
251     * Summary-composition binding with the caller signature available for
252     * writable-lvalue validation. A PL/SQL pure-IN formal can be read inside
253     * its routine but cannot receive a callee OUT value; treating its bare
254     * spelling as writable publishes lineage for a call Oracle rejects.
255     */
256    public BoundCall bind(CallSiteRef site, String callerDb,
257            RoutineIdentity callerIdentity) {
258        BoundCall bound = bind(site, callerDb);
259        BindingResult result = bound.getResult();
260        if (vendor != EDbVendor.dbvoracle || callerIdentity == null
261                || result.getStatus() != BindingStatus.RESOLVED
262                || result.getOutputs().isEmpty()) {
263            return bound;
264        }
265        Map<Integer, WritableTarget> outputs =
266                new LinkedHashMap<Integer, WritableTarget>(result.getOutputs());
267        List<BindingDiagnostic> diagnostics =
268                new ArrayList<BindingDiagnostic>(result.getDiagnostics());
269        boolean rejected = false;
270        for (Map.Entry<Integer, WritableTarget> output
271                : result.getOutputs().entrySet()) {
272            Integer actualPos = bound.getActualPositionByFormal().get(
273                    output.getKey());
274            if (actualPos == null || actualPos < 0
275                    || actualPos >= site.getActuals().size()) {
276                continue;
277            }
278            CallSiteRef.Actual actual = site.getActuals().get(actualPos);
279            int callerFormal = callerFormalPosition(actual.getBareReference(),
280                    callerIdentity);
281            if (callerFormal >= 0
282                    && !declaredOut(callerIdentity, callerFormal)) {
283                outputs.remove(output.getKey());
284                diagnostics.add(new BindingDiagnostic(
285                        BindingDiagnosticCode.PROC_CALL_OUT_TARGET_NOT_WRITABLE,
286                        "caller pure-IN formal "
287                                + callerIdentity.getRawParamNames().get(
288                                        callerFormal)
289                                + " bound to OUT formal #" + output.getKey()
290                                + " of " + site.getRaw().getRawName()));
291                rejected = true;
292            }
293        }
294        if (!rejected) {
295            return bound;
296        }
297        BindingResult filtered = new BindingResult(result.getStatus(),
298                result.getResolved(), result.getCandidates(),
299                result.getInputs(), outputs, result.getReturnTarget(),
300                result.getResultSets(), BindingCompleteness.PARTIAL,
301                diagnostics);
302        return new BoundCall(filtered, bound.getActualPositionByFormal(),
303                bound.getCalleeUnitText());
304    }
305
306    /** Caller formal position of one bare actual, or -1 for a local/table
307     *  reference. Matching uses raw declaration spellings through the vendor
308     *  equality facade; stored canonical text is never folded twice. */
309    private int callerFormalPosition(TObjectName bare,
310            RoutineIdentity callerIdentity) {
311        if (bare == null) {
312            return -1;
313        }
314        String actualName = bare.toString();
315        List<String> parts = SQLUtil.parseNames(actualName);
316        // Only an unqualified reference can be identified as the caller's
317        // formal from spelling alone.  Suffix-matching a qualified reference
318        // such as rec.p_in against formal p_in would reject a valid writable
319        // record field as though it were the read-only formal.
320        if (parts.size() != 1) {
321            return -1;
322        }
323        actualName = parts.get(0);
324        if (actualName.startsWith("@")) {
325            actualName = actualName.substring(1);
326        }
327        for (int i = 0; i < callerIdentity.getRawParamNames().size(); i++) {
328            String declared = callerIdentity.getRawParamNames().get(i);
329            if (declared.startsWith("@")) {
330                declared = declared.substring(1);
331            }
332            if (SQLUtil.sameName(vendor, ESQLDataObjectType.dotColumn,
333                    actualName, declared)) {
334                return i;
335            }
336        }
337        return -1;
338    }
339
340    /** Pure OUT (not INOUT/OUTPUT) — Oracle's no-input-value mode. */
341    private static boolean isPureOutMode(RoutineIdentity identity, int formalPos) {
342        if (formalPos >= identity.getParamModes().size()) {
343            return false;
344        }
345        String mode = identity.getParamModes().get(formalPos);
346        if (mode == null) {
347            return false;
348        }
349        String upper = mode.toUpperCase(java.util.Locale.ROOT); // non-identifier-compare: parameter-mode token vocabulary
350        return upper.equals("OUT");
351    }
352
353    /** Declared OUT/OUTPUT/INOUT check over the identity's mode vocabulary. */
354    private static boolean declaredOut(RoutineIdentity identity, int formalPos) {
355        if (formalPos >= identity.getParamModes().size()) {
356            return false;
357        }
358        String mode = identity.getParamModes().get(formalPos);
359        if (mode == null) {
360            return false;
361        }
362        String upper = mode.toUpperCase(java.util.Locale.ROOT); // non-identifier-compare: parameter-mode token vocabulary
363        return upper.equals("OUT") || upper.equals("OUTPUT") || upper.equals("INOUT");
364    }
365
366    /**
367     * The candidate definitions compatible with one call: name-matched under
368     * the caller context, then named/positional + default-fill compatible.
369     */
370    private List<Candidate> candidatesFor(String callName, String callerDb,
371            List<CallSiteRef.Actual> actuals) {
372        List<Candidate> result = new ArrayList<Candidate>();
373        for (String unitText : catalog.unitTexts()) {
374            RoutineCatalog.UnitIndex index = catalog.indexOf(unitText);
375            if (index == null) {
376                continue;
377            }
378            for (RoutineCatalog.Definition def : index.definitions) {
379                if (def.identity == null) {
380                    continue;
381                }
382                if (!nameMatches(callName, callerDb, def)) {
383                    continue;
384                }
385                if (!arityCompatible(actuals, def.identity)) {
386                    continue;
387                }
388                result.add(new Candidate(def, unitText));
389            }
390            // Package members are PACKAGE_SCOPE_DEFERRED until B4: they share
391            // one statement and cannot be summarized per definition yet, so
392            // binding to them would compose against a summary that does not
393            // exist. They are deliberately NOT candidates in B3.
394        }
395        return result;
396    }
397
398    /** Name match under caller context — EXACTLY the P5 resolver rules
399     *  ({@code DynamicSqlLineageResolver.callMatchesProc}): a one-part call
400     *  matches by simple name under database agreement; equal part counts
401     *  compare fully; the 2↔3 part asymmetric pairs match only with the
402     *  documented database checks; every other arity combination REFUSES —
403     *  schema/database defaulting is environment-dependent and an unknown
404     *  segment is never treated as a wildcard (codex-B3-r1 finding 1). */
405    private boolean nameMatches(String callName, String callerDb,
406            RoutineCatalog.Definition def) {
407        List<String> callParts = SQLUtil.parseNames(callName);
408        if (callParts.isEmpty()) {
409            return false;
410        }
411        String callSimple = callParts.get(callParts.size() - 1);
412        String defSimple = def.simple;
413        if (vendor == EDbVendor.dbvmssql) {
414            // Numbered procedures: base names must match AND the ;N group
415            // must agree (";1" ≡ unnumbered) — codex-B3-r1 finding 6. The
416            // definition's group lives on its IDENTITY (token-scanned at
417            // catalog time; the parser drops ";N" from the AST name text).
418            String callGroup = RoutineCatalog.mssqlNumberedSuffixOf(callSimple);
419            String defGroup = def.identity.getOverloadDiscriminator();
420            if (defGroup != null && (defGroup.length() == 0 || "1".equals(defGroup))) { // non-identifier-compare: numeric group suffix
421                defGroup = null;
422            }
423            String textGroup = RoutineCatalog.mssqlNumberedSuffixOf(defSimple);
424            if (defGroup == null && textGroup != null) {
425                defGroup = textGroup;
426            }
427            if (callGroup == null ? defGroup != null : !callGroup.equals(defGroup)) { // non-identifier-compare: numeric group suffix
428                return false;
429            }
430            callSimple = RoutineCatalog.stripMssqlNumberedSuffix(callSimple);
431            defSimple = RoutineCatalog.stripMssqlNumberedSuffix(defSimple);
432        }
433        if (!SQLUtil.sameName(vendor, ESQLDataObjectType.dotProcedure,
434                callSimple, defSimple)) {
435            return false;
436        }
437        if (vendor == EDbVendor.dbvmssql) {
438            // Database agreement first (a 3-part call carries its own db).
439            if (callParts.size() < 3 && !sameEffectiveDb(def.db, callerDb)) {
440                return false;
441            }
442            List<String> defParts = definitionNameParts(def);
443            if (callParts.size() == 1) {
444                return true; // simple-name rule under db agreement (P5)
445            }
446            if (callParts.size() == defParts.size()) {
447                // The simple name (last segment) was already compared above
448                // with the ;N group stripped — compare the container
449                // segments only.
450                for (int i = 2; i <= callParts.size(); i++) {
451                    ESQLDataObjectType type = i == 2
452                            ? ESQLDataObjectType.dotSchema
453                            : ESQLDataObjectType.dotCatalog;
454                    if (!SQLUtil.sameName(vendor, type,
455                            callParts.get(callParts.size() - i),
456                            defParts.get(defParts.size() - i))) {
457                        return false;
458                    }
459                }
460                return true;
461            }
462            if (callParts.size() == 2 && defParts.size() == 3) {
463                // db agreement already established; compare schema.name
464                return SQLUtil.sameName(vendor, ESQLDataObjectType.dotSchema,
465                        callParts.get(0), defParts.get(1));
466            }
467            if (callParts.size() == 3 && defParts.size() == 2) {
468                // explicit call catalog must equal the KNOWN effective db
469                return def.db != null
470                        && SQLUtil.sameName(vendor, ESQLDataObjectType.dotCatalog,
471                                callParts.get(0), def.db)
472                        && SQLUtil.sameName(vendor, ESQLDataObjectType.dotSchema,
473                                callParts.get(1), defParts.get(0));
474            }
475            // Other combinations (e.g. qualified call vs one-part definition):
476            // conservative no-match, exactly like the P5 resolver.
477            return false;
478        }
479        // Oracle: one-part call matches by simple name; a schema-qualified
480        // call requires the definition's schema KNOWN and equal (an unknown
481        // definition schema is not a wildcard).
482        if (callParts.size() == 1) {
483            return true;
484        }
485        if (callParts.size() == 2) {
486            return def.schema != null
487                    && SQLUtil.sameName(vendor, ESQLDataObjectType.dotSchema,
488                            callParts.get(0), def.schema);
489        }
490        return false;
491    }
492
493    /** P5 {@code sameEffectiveDatabase} for one/two-part calls: both unknown
494     *  ⇒ same analysis default; one unknown ⇒ NOT provably same, refuse. */
495    private boolean sameEffectiveDb(String defDb, String callerDb) {
496        if (defDb == null && callerDb == null) {
497            return true;
498        }
499        if (defDb == null || callerDb == null) {
500            return false;
501        }
502        return SQLUtil.sameName(vendor, ESQLDataObjectType.dotCatalog,
503                defDb, callerDb);
504    }
505
506    /** The definition's declared name segments (as written, catalog-first). */
507    private List<String> definitionNameParts(RoutineCatalog.Definition def) {
508        if (def.stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure) {
509            gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure proc =
510                    (gudusoft.gsqlparser.stmt.mssql.TMssqlCreateProcedure) def.stmt;
511            if (proc.getProcedureName() != null) {
512                return SQLUtil.parseNames(proc.getProcedureName().toString());
513            }
514        }
515        List<String> parts = new ArrayList<String>();
516        if (def.schema != null) {
517            parts.add(def.schema);
518        }
519        parts.add(def.simple);
520        return parts;
521    }
522
523    /** Schema segment of an MSSQL definition name (identity carries it). */
524    private String definitionSchema(RoutineCatalog.Definition def) {
525        return def.identity == null || def.identity.getCanonSchema() == null
526                || def.identity.getCanonSchema().length() == 0
527                ? null : def.identity.getCanonSchema();
528    }
529
530    /** Named/positional + default-fill compatibility (§2.1b step 1). */
531    private boolean arityCompatible(List<CallSiteRef.Actual> actuals,
532            RoutineIdentity identity) {
533        int paramCount = identity.getParamNames().size();
534        if (actuals.size() > paramCount) {
535            return false;
536        }
537        Map<Integer, Integer> mapped = mapActualsToFormals(actuals, identity);
538        if (mapped == null) {
539            return false;
540        }
541        for (int p = 0; p < paramCount; p++) {
542            if (!mapped.containsKey(p) && !identity.getParamHasDefault().get(p)) {
543                return false;
544            }
545        }
546        // An explicit DEFAULT actual requires a DECLARED default on its
547        // formal (codex-B3-r1 finding 11).
548        for (Map.Entry<Integer, Integer> e : mapped.entrySet()) {
549            if (actuals.get(e.getValue()).isDefaultToken()
550                    && !identity.getParamHasDefault().get(e.getKey())) {
551                return false;
552            }
553        }
554        return true;
555    }
556
557    /** Formal position → actual position; null when a named actual matches no
558     *  formal or positional/named mixing is inconsistent. */
559    private Map<Integer, Integer> mapActualsToFormals(
560            List<CallSiteRef.Actual> actuals, RoutineIdentity identity) {
561        Map<Integer, Integer> result = new LinkedHashMap<Integer, Integer>();
562        List<String> paramNames = identity.getParamNames();
563        boolean namedSeen = false;
564        for (CallSiteRef.Actual actual : actuals) {
565            if (actual.getNamedFormal() != null) {
566                namedSeen = true;
567                int formal = formalPositionOf(actual.getNamedFormal(), identity);
568                if (formal < 0 || result.containsKey(formal)) {
569                    return null;
570                }
571                result.put(formal, actual.getPosition());
572            } else {
573                if (namedSeen) {
574                    // positional after named is invalid in both vendors
575                    return null;
576                }
577                int formal = actual.getPosition();
578                if (formal >= paramNames.size()) {
579                    return null;
580                }
581                result.put(formal, actual.getPosition());
582            }
583        }
584        return result;
585    }
586
587    /** Position of a named actual's formal; -1 when absent. Compared over
588     *  the RAW declared spellings via the equality facade — re-keying the
589     *  stored canonical text would double-fold quoted names (a declared
590     *  {@code "x"} stored as {@code x} must not re-fold to {@code X} —
591     *  codex-B3-r2 finding 12). */
592    private int formalPositionOf(String namedFormal, RoutineIdentity identity) {
593        List<String> rawNames = identity.getRawParamNames();
594        for (int i = 0; i < rawNames.size(); i++) {
595            if (SQLUtil.sameName(vendor, ESQLDataObjectType.dotColumn,
596                    stripAt(namedFormal), stripAt(rawNames.get(i)))) {
597                return i;
598            }
599        }
600        return -1;
601    }
602
603    /** Both call-site and declaration @-prefix variants occur; the prefix is
604     *  not an identifier character, so it is stripped outside folding. */
605    private static String stripAt(String name) {
606        return name != null && name.startsWith("@") ? name.substring(1) : name;
607    }
608}