001package gudusoft.gsqlparser.dlineage.dynamicsql;
002
003import gudusoft.gsqlparser.EDbVendor;
004import gudusoft.gsqlparser.EExpressionType;
005import gudusoft.gsqlparser.TBaseType;
006import gudusoft.gsqlparser.TCustomSqlStatement;
007import gudusoft.gsqlparser.TStatementList;
008import gudusoft.gsqlparser.dlineage.DataFlowAnalyzer;
009import gudusoft.gsqlparser.dlineage.dataflow.model.Option;
010import gudusoft.gsqlparser.dlineage.dataflow.model.xml.column;
011import gudusoft.gsqlparser.dlineage.dataflow.model.xml.dataflow;
012import gudusoft.gsqlparser.dlineage.dataflow.model.xml.relationship;
013import gudusoft.gsqlparser.dlineage.dataflow.model.xml.sourceColumn;
014import gudusoft.gsqlparser.dlineage.dataflow.model.xml.table;
015import gudusoft.gsqlparser.dlineage.dynamicsql.RoutineSummaryEdge.Endpoint;
016import gudusoft.gsqlparser.dlineage.dynamicsql.RoutineSummaryEdge.EndpointKind;
017import gudusoft.gsqlparser.nodes.TExpression;
018import gudusoft.gsqlparser.nodes.TFunctionCall;
019import gudusoft.gsqlparser.nodes.TObjectName;
020import gudusoft.gsqlparser.nodes.TParseTreeVisitor;
021import gudusoft.gsqlparser.sqlenv.ESQLDataObjectType;
022import gudusoft.gsqlparser.util.SQLUtil;
023
024import java.util.ArrayDeque;
025import java.util.ArrayList;
026import java.util.Collections;
027import java.util.Deque;
028import java.util.HashMap;
029import java.util.HashSet;
030import java.util.LinkedHashMap;
031import java.util.LinkedHashSet;
032import java.util.List;
033import java.util.Locale;
034import java.util.Map;
035import java.util.Set;
036import java.util.TreeSet;
037
038/**
039 * B2 (design {@code routine-summary-scc-design.md} §2.2, §3): intraprocedural
040 * base-summary extraction — the reachability QUOTIENT of one routine's
041 * analyzed relation graph.
042 *
043 * <p><b>No-inline-callee / binding-independence — how it is actually
044 * enforced</b> (corrected per codex-B2-r1 finding 1): a fresh
045 * {@link DataFlowAnalyzer} instance blocks CROSS-FILE leakage only; the
046 * analyzer will still inline a SAME-UNIT callee's body through its
047 * procedure-DDL map. The contract therefore rests on PER-DEFINITION
048 * slicing: {@link #extractAll(String)} splits a unit into single-definition
049 * texts and each slice is analyzed with no sibling present, so callee
050 * bodies are absent by construction and calls surface as raw call sites
051 * (+ CALLS_UNRESOLVED). {@link #extract(String)} requires a
052 * single-definition input for the same reason. Oracle package statements
053 * are the documented exception (members share one statement; B4).
054 *
055 * <p><b>Closure semantics</b> (mirrors the inline engine's fdd/fdr duality,
056 * never OR-merging): per boundary pair, an {@code fdd} summary edge means at
057 * least one interior path made of {@code fdd} hops only; an {@code fdr}
058 * summary edge means at least one interior path containing an {@code fdr}
059 * hop. Both may coexist. Propagation is an iterative worklist (explicit
060 * {@link Deque}; the relation graph is arbitrary — cycles from variable
061 * reassignment converge because the propagated sets are monotone).
062 *
063 * <p>The extractor caches per definition text (analysis-scoped: one
064 * extractor instance per analysis).
065 */
066public final class RoutineSummaryExtractor {
067
068    private final EDbVendor vendor;
069    private final Map<String, RoutineSummary> cache = new HashMap<String, RoutineSummary>();
070
071    public RoutineSummaryExtractor(EDbVendor vendor) {
072        this.vendor = vendor;
073    }
074
075    /**
076     * Extract base summaries for EVERY routine defined in a unit, each from a
077     * PER-DEFINITION slice analyzed alone (codex-B2-r1 finding 1: analyzing
078     * the whole unit lets the analyzer inline same-unit callee bodies through
079     * its procedure-DDL map — slicing is what actually enforces
080     * no-inline-callee; a same-unit callee is then simply absent, and the
081     * call surfaces as a raw call site + CALLS_UNRESOLVED).
082     *
083     * <p>Oracle packages: members share one CREATE PACKAGE [BODY] statement
084     * and cannot be sliced apart — the package statement is summarized whole,
085     * and intra-package sibling composition is deferred to B4 (parity slice).
086     */
087    public List<RoutineSummary> extractAll(String unitText) {
088        List<RoutineSummary> summaries = new ArrayList<RoutineSummary>();
089        RoutineCatalog catalog = new RoutineCatalog(vendor,
090                Collections.singletonList(unitText),
091                new HashMap<String, TStatementList>());
092        RoutineCatalog.UnitIndex index = catalog.indexOf(unitText);
093        if (index == null) {
094            summaries.add(new RoutineSummary(null, null, null,
095                    RoutineSummary.Completeness.OPAQUE,
096                    Collections.singletonList("PARSE_FAILED")));
097            return summaries;
098        }
099        for (RoutineCatalog.Definition def : index.definitions) {
100            summaries.add(extract(definitionSlice(def)));
101        }
102        // Oracle packages: members live in memberDefinitions, not definitions
103        // (codex-B2-r2 new issue 1). B4a summarizes each member from its OWN
104        // re-wrapped single-member slice (see RoutineCatalog.PackageContext).
105        // A package whose members cannot all be sliced falls back to the B2/B3
106        // behavior — one whole-package summary held PARTIAL by the
107        // PACKAGE_SCOPE_DEFERRED gate — so a slicing failure degrades to the
108        // previously shipped result instead of dropping the package.
109        // Sliceability is decided PER PACKAGE, not per member: a package is
110        // either fully represented by per-member slices or not sliced at all.
111        // Mixing the two would emit per-member summaries AND a whole-package
112        // summary covering those same routines — double coverage.
113        Map<TCustomSqlStatement, List<RoutineCatalog.Definition>> byPackage =
114                new LinkedHashMap<TCustomSqlStatement, List<RoutineCatalog.Definition>>();
115        for (RoutineCatalog.Definition member : index.memberDefinitions) {
116            TCustomSqlStatement pkg = packageStatementOf(member);
117            if (pkg == null) {
118                continue;
119            }
120            List<RoutineCatalog.Definition> members = byPackage.get(pkg);
121            if (members == null) {
122                members = new ArrayList<RoutineCatalog.Definition>();
123                byPackage.put(pkg, members);
124            }
125            members.add(member);
126        }
127        for (Map.Entry<TCustomSqlStatement, List<RoutineCatalog.Definition>> e
128                : byPackage.entrySet()) {
129            List<RoutineSummary> sliced = sliceMembers(e.getKey(), e.getValue());
130            if (sliced != null) {
131                summaries.addAll(sliced);
132            } else {
133                summaries.add(extract(e.getKey().toString()));
134            }
135        }
136        return summaries;
137    }
138
139    /**
140     * Per-member summaries for ONE package, or null when the package must not
141     * be sliced at all (caller then falls back to the B2/B3 whole-package
142     * summary, held PARTIAL by the PACKAGE_SCOPE_DEFERRED gate — i.e. the
143     * previously shipped extraction SHAPE; it does not recover lineage the
144     * analyzer never produced).
145     *
146     * <p>Returning null on ANY problem is what keeps slicing sound: a package
147     * is either fully covered by member slices or not sliced at all.
148     */
149    private List<RoutineSummary> sliceMembers(TCustomSqlStatement pkg,
150            List<RoutineCatalog.Definition> members) {
151        // A package initialization block (`... BEGIN <stmts> END pkg;`) is
152        // stored on the PACKAGE's own body statements, not on any member, so
153        // no member slice can carry it (codex-B4a-r2 finding 1).
154        //
155        // MEASURED: dlineage emits ZERO relationships for a package
156        // initializer today, so nothing is lost either way — verified against
157        // master with the whole B4a diff stashed. This guard therefore does
158        // not fix a regression; it keeps slicing from claiming coverage of a
159        // construct it structurally cannot carry, so that if the analyzer
160        // ever learns to read initializers the whole-package path picks them
161        // up instead of the slice silently dropping them.
162        if (pkg instanceof gudusoft.gsqlparser.stmt.TBlockSqlStatement) {
163            gudusoft.gsqlparser.stmt.TBlockSqlStatement block =
164                    (gudusoft.gsqlparser.stmt.TBlockSqlStatement) pkg;
165            if (block.getBodyStatements() != null
166                    && block.getBodyStatements().size() > 0) {
167                return null;
168            }
169        }
170        List<RoutineSummary> sliced = new ArrayList<RoutineSummary>();
171        for (RoutineCatalog.Definition member : members) {
172            // A SPEC declaration has no body — nothing to summarize. Its
173            // signature already reached the catalog via memberDefinitions.
174            // specOnly is a PACKAGE-wide flag, so it does NOT catch a forward
175            // declaration sitting inside a package BODY; that needs the
176            // per-member body check (codex-B4a-r1 finding 1).
177            if (member.specOnly || !hasBody(member.stmt)) {
178                continue;
179            }
180            String slice = memberSlice(member);
181            if (slice == null) {
182                return null;
183            }
184            RoutineSummary summary = extract(slice);
185            if (summary.getCompleteness() == RoutineSummary.Completeness.OPAQUE) {
186                // Re-wrap did not survive the parser (unusual declaration
187                // section, dialect edge). Fall back rather than publish OPAQUE.
188                return null;
189            }
190            sliced.add(summary);
191        }
192        return sliced;
193    }
194
195    /**
196     * True when a package member actually carries an implementation.
197     *
198     * <p>A forward declaration inside a package BODY ({@code PROCEDURE foo(p
199     * NUMBER);} ahead of its definition) and a call specification (EXTERNAL /
200     * LANGUAGE JAVA) both parse to the same node type as a real member but
201     * hold zero body statements. Summarizing one would publish a SECOND
202     * summary under the implementation's own {@link RoutineIdentity}; the
203     * applier's duplicate-definition handling then discards that identity
204     * outright and the real routine's effects are lost (codex-B4a-r1
205     * finding 1). Body presence is the per-member discriminator —
206     * {@code Definition.specOnly} is package-wide and cannot see this.
207     */
208    private static boolean hasBody(TCustomSqlStatement stmt) {
209        if (!(stmt instanceof gudusoft.gsqlparser.stmt.TBlockSqlStatement)) {
210            // Unknown member shape — keep the pre-existing behavior of
211            // summarizing it rather than silently dropping a real routine.
212            return true;
213        }
214        gudusoft.gsqlparser.stmt.TBlockSqlStatement block =
215                (gudusoft.gsqlparser.stmt.TBlockSqlStatement) stmt;
216        return block.getBodyStatements() != null
217                && block.getBodyStatements().size() > 0;
218    }
219
220    /**
221     * B4a: rebuild ONE package member as a standalone single-member package —
222     * header + package globals + this member only + {@code END}. Returns null
223     * when the member carries no re-wrap context or its own text is
224     * unavailable.
225     */
226    private static String memberSlice(RoutineCatalog.Definition member) {
227        RoutineCatalog.PackageContext ctx = member.pkgContext;
228        if (ctx == null || ctx.qualifiedName == null || member.stmt == null) {
229            return null;
230        }
231        String memberText = member.stmt.toString();
232        if (memberText == null) {
233            return null;
234        }
235        memberText = memberText.trim();
236        if (memberText.length() == 0) {
237            return null;
238        }
239        // Member nodes end at END <name> with the terminating semicolon owned
240        // by the enclosing declare list, so it must be re-added here.
241        if (!memberText.endsWith(";")) {
242            memberText = memberText + ";";
243        }
244        StringBuilder slice = new StringBuilder();
245        slice.append("CREATE OR REPLACE PACKAGE BODY ")
246                .append(ctx.qualifiedName).append(" AS\n");
247        if (ctx.globalsText != null) {
248            slice.append(ctx.globalsText);
249        }
250        slice.append("  ").append(memberText).append("\n");
251        slice.append("END");
252        if (ctx.endName != null && ctx.endName.length() > 0) {
253            slice.append(" ").append(ctx.endName);
254        }
255        slice.append(";\n");
256        return slice.toString();
257    }
258
259    /** The enclosing CREATE PACKAGE statement of a member definition. */
260    private static TCustomSqlStatement packageStatementOf(
261            RoutineCatalog.Definition member) {
262        TCustomSqlStatement stmt = member.stmt;
263        while (stmt != null
264                && !(stmt instanceof gudusoft.gsqlparser.stmt.oracle.TPlsqlCreatePackage)) {
265            stmt = stmt.getParentStmt();
266        }
267        return stmt;
268    }
269
270    /** The standalone analyzable text of one definition (its own statement,
271     *  re-anchored under its effective USE database when one applies). */
272    private String definitionSlice(RoutineCatalog.Definition def) {
273        String text = def.stmt.toString();
274        if (vendor == EDbVendor.dbvmssql && def.db != null && def.db.length() > 0) {
275            return "USE " + def.db + "\nGO\n" + text;
276        }
277        return text;
278    }
279
280    /** Extract (or return the cached) base summary of ONE routine definition.
281     *  The input must contain a single definition — feeding a multi-routine
282     *  unit here reintroduces same-unit callee inlining; use
283     *  {@link #extractAll(String)} for whole units. */
284    public RoutineSummary extract(String routineDefinitionText) {
285        RoutineSummary cached = cache.get(routineDefinitionText);
286        if (cached != null) {
287            return cached;
288        }
289        RoutineSummary summary = doExtract(routineDefinitionText);
290        cache.put(routineDefinitionText, summary);
291        return summary;
292    }
293
294    private RoutineSummary doExtract(String text) {
295        RoutineCatalog catalog = new RoutineCatalog(vendor,
296                Collections.singletonList(text),
297                new HashMap<String, TStatementList>());
298        RoutineCatalog.UnitIndex index = catalog.indexOf(text);
299        if (index == null) {
300            // Unit failed to parse — dlineage would swallow this into its
301            // error list, but §2.6 reserves OPAQUE for exactly this case.
302            return new RoutineSummary(null, null, null,
303                    RoutineSummary.Completeness.OPAQUE,
304                    Collections.singletonList("PARSE_FAILED"));
305        }
306        RoutineIdentity identity = identityOf(index);
307        dataflow df = analyze(text);
308        if (df == null) {
309            return new RoutineSummary(identity, null, null,
310                    RoutineSummary.Completeness.OPAQUE,
311                    Collections.singletonList("ANALYSIS_FAILED"));
312        }
313        RelationGraph graph = RelationGraph.of(df);
314        Closure closure = closeGraph(graph);
315        Set<RoutineSummaryEdge> edges = closure.edges;
316        CollectedBody body = collectCallSiteAsts(text);
317        List<CallSiteRef> callSites = callSites(body.sites, df, graph, closure);
318        List<RoutineSummary.RawCallSite> calls =
319                new ArrayList<RoutineSummary.RawCallSite>();
320        for (CallSiteRef site : callSites) {
321            calls.add(site.getRaw());
322        }
323        List<String> reasons = new ArrayList<String>();
324        if (df.getErrors() != null && !df.getErrors().isEmpty()) {
325            reasons.add("ANALYSIS_ERRORS");
326        }
327        if (containsDynamicSql(text)) {
328            reasons.add("DYNAMIC_SQL_UNRESOLVED");
329        }
330        if (!calls.isEmpty()) {
331            // Base summaries never include callee effects (no-inline-callee):
332            // any call site means the summary under-approximates until B3
333            // composes it — a COMPLETE claim here would let B6 replace inline
334            // analysis and silently lose the callee flow (codex-B2-r1 f6).
335            reasons.add("CALLS_UNRESOLVED");
336        }
337        if (index != null && index.definitions.size()
338                + index.memberDefinitions.size() > 1) {
339            reasons.add("MULTIPLE_DEFINITIONS_IN_UNIT");
340        }
341        // Deferred-construct gates (codex-B2-r2 finding 2): the B2 endpoint
342        // model has no RETURN / ref-cursor / package-global kinds, so any
343        // routine whose effects can flow through one must be PARTIAL — a
344        // COMPLETE claim would let B6 replace inline analysis and lose them.
345        if (identity != null
346                && (identity.getKind() == RoutineKind.FUNCTION
347                        || identity.getKind() == RoutineKind.PACKAGE_MEMBER_FUNCTION)) {
348            reasons.add("RETURN_ENDPOINT_DEFERRED");
349        }
350        if (identity != null) {
351            for (String typeText : identity.getParamTypeNames()) {
352                // non-identifier-compare: normalized declared-type text screen
353                if (typeText.contains("CURSOR")) {
354                    reasons.add("REF_CURSOR_DEFERRED");
355                    break;
356                }
357            }
358        }
359        if (index != null && !index.memberDefinitions.isEmpty()) {
360            reasons.add("PACKAGE_SCOPE_DEFERRED");
361        }
362        // A body-level RETURN with a value defers the routine for BOTH kinds
363        // (T-SQL procedures return status values too — codex-B3-r2 finding
364        // 4b), and its pseudo-resultset is indistinguishable from a genuine
365        // SELECT result set in the model — suppress positional INSERT…EXEC
366        // composition entirely (sound under-approximation; finding 4c).
367        Map<String, List<String>> shapes = graph.terminalShapes;
368        if (body.returnsValue) {
369            if (!reasons.contains("RETURN_ENDPOINT_DEFERRED")) {
370                reasons.add("RETURN_ENDPOINT_DEFERRED");
371            }
372            shapes = Collections.emptyMap();
373        }
374        RoutineSummary.Completeness completeness = reasons.isEmpty()
375                ? RoutineSummary.Completeness.COMPLETE
376                : RoutineSummary.Completeness.PARTIAL;
377        return new RoutineSummary(identity, edges, calls, callSites,
378                shapes, completeness, reasons);
379    }
380
381    // ------------------------------------------------------------------
382    // Graph model shared by the worklist closure and (in tests) the naive
383    // differential implementation.
384    // ------------------------------------------------------------------
385
386    /** The analyzed relation graph with boundary classification. */
387    static final class RelationGraph {
388        /** node key -> outgoing (targetKey, "1" when the hop is fdr) pairs. */
389        final Map<String, List<String[]>> out = new HashMap<String, List<String[]>>();
390        /** node key -> boundary endpoint (absent for interior nodes). */
391        final Map<String, Endpoint> boundary = new HashMap<String, Endpoint>();
392        /** interior function-hop display name per node (absent when none). */
393        final Map<String, String> functionName = new HashMap<String, String>();
394        final Set<String> nodes = new LinkedHashSet<String>();
395        /** B3: terminal result-set qualified display name -> its columns in
396         *  projection order (for positional INSERT…EXEC composition). */
397        final Map<String, List<String>> terminalShapes =
398                new LinkedHashMap<String, List<String>>();
399
400        static RelationGraph of(dataflow df) {
401            RelationGraph g = new RelationGraph();
402            Map<String, table> parentById = new HashMap<String, table>();
403            Map<String, EndpointKind> kindByParent = new HashMap<String, EndpointKind>();
404            Set<String> resultsetIds = new HashSet<String>();
405            Map<String, List<String>> terminalNameOccurrences =
406                    new HashMap<String, List<String>>();
407            if (df.getTables() != null) {
408                for (table t : df.getTables()) {
409                    parentById.put(t.getId(), t);
410                    String name = t.getName() == null ? "" : t.getName();
411                    kindByParent.put(t.getId(), name.startsWith("#")
412                            ? EndpointKind.TEMP_COLUMN : EndpointKind.TABLE_COLUMN);
413                }
414            }
415            if (df.getVariables() != null) {
416                for (table v : df.getVariables()) {
417                    parentById.put(v.getId(), v);
418                    String subType = v.getSubType() == null ? ""
419                            : v.getSubType().toLowerCase(Locale.ROOT);
420                    // non-identifier-compare: subType is a fixed mode-token vocabulary
421                    boolean formal = subType.equals("in") || subType.equals("out")
422                            || subType.equals("output") || subType.equals("inout")
423                            || subType.equals("argument");
424                    if (formal) {
425                        kindByParent.put(v.getId(), EndpointKind.FORMAL);
426                    }
427                    // locals stay interior (absent from kindByParent)
428                }
429            }
430            List<String> rsIdOrder = new ArrayList<String>();
431            if (df.getResultsets() != null) {
432                for (table rs : df.getResultsets()) {
433                    parentById.put(rs.getId(), rs);
434                    resultsetIds.add(rs.getId());
435                    rsIdOrder.add(rs.getId());
436                }
437            }
438            // Edges + node registration.
439            Set<String> parentsWithOutgoing = new HashSet<String>();
440            if (df.getRelationships() != null) {
441                for (relationship rel : df.getRelationships()) {
442                    String type = rel.getType();
443                    // non-identifier-compare: relationship type token
444                    boolean fdd = "fdd".equals(type);
445                    boolean fdr = "fdr".equals(type);
446                    if (!fdd && !fdr) {
447                        continue;
448                    }
449                    if (rel.getTarget() == null || rel.getSources() == null) {
450                        continue;
451                    }
452                    String targetKey = key(rel.getTarget().getParent_id(),
453                            rel.getTarget().getColumn());
454                    for (sourceColumn src : rel.getSources()) {
455                        String sourceKey = key(src.getParent_id(), src.getColumn());
456                        g.nodes.add(sourceKey);
457                        g.nodes.add(targetKey);
458                        List<String[]> hops = g.out.get(sourceKey);
459                        if (hops == null) {
460                            hops = new ArrayList<String[]>();
461                            g.out.put(sourceKey, hops);
462                        }
463                        hops.add(new String[] { targetKey, fdr ? "1" : "0" });
464                        parentsWithOutgoing.add(src.getParent_id());
465                    }
466                }
467            }
468            // §4.1 ROW-dominates across the quotient: an fdr edge into a
469            // RESULTSET's synthetic RelationRows column is a row gate on that
470            // statement occurrence — fan it out as fdr hops to the sibling
471            // columns so a filter composes into downstream fdr edges (the
472            // design's base.f → t rows → c example). RESULTSET parents only:
473            // resultset ids are occurrence-scoped (one per statement), while
474            // physical-table models are REUSED across statements, so fanning
475            // out on a table parent would smear one statement's condition
476            // onto unrelated statements' flows (codex-B2-r1 finding 4). A
477            // table's row gate stays visible as a boundary edge targeting
478            // the table's own RelationRows node instead.
479            List<String> relationRowsNodes = new ArrayList<String>();
480            for (String node : g.nodes) {
481                int sep = node.indexOf(' ');
482                // non-identifier-compare: RelationRows is dlineage's fixed synthetic column name
483                if ("RelationRows".equals(node.substring(sep + 1))
484                        && resultsetIds.contains(node.substring(0, sep))) {
485                    relationRowsNodes.add(node);
486                }
487            }
488            for (String rowsNode : relationRowsNodes) {
489                String parentId = rowsNode.substring(0, rowsNode.indexOf(' '));
490                for (String sibling : new ArrayList<String>(g.nodes)) {
491                    if (sibling.equals(rowsNode)
492                            || !sibling.startsWith(parentId + " ")) {
493                        continue;
494                    }
495                    List<String[]> hops = g.out.get(rowsNode);
496                    if (hops == null) {
497                        hops = new ArrayList<String[]>();
498                        g.out.put(rowsNode, hops);
499                    }
500                    hops.add(new String[] { sibling, "1" });
501                }
502            }
503            // Terminal-resultset duplicate-name ordinals in OCCURRENCE order
504            // (df.getResultsets() order — codex-B2-r2: lexicographic id sort
505            // is not occurrence order and both differential sides would have
506            // repeated the mistake).
507            for (String id : rsIdOrder) {
508                if (parentsWithOutgoing.contains(id)) {
509                    continue;
510                }
511                table rs = parentById.get(id);
512                if (rs == null || rs.isFunction()) {
513                    continue;
514                }
515                String name = rs.getName() == null ? id : rs.getName();
516                List<String> ids = terminalNameOccurrences.get(name);
517                if (ids == null) {
518                    ids = new ArrayList<String>();
519                    terminalNameOccurrences.put(name, ids);
520                }
521                ids.add(id);
522            }
523            // Boundary classification per node.
524            for (String node : g.nodes) {
525                int sep = node.indexOf(' ');
526                String parentId = node.substring(0, sep);
527                String column = node.substring(sep + 1);
528                EndpointKind kind = kindByParent.get(parentId);
529                table parent = parentById.get(parentId);
530                String parentName = parent == null || parent.getName() == null
531                        ? parentId : parent.getName();
532                if (kind != null) {
533                    g.boundary.put(node, new Endpoint(kind, parentName, column));
534                } else if (resultsetIds.contains(parentId)) {
535                    boolean isFunction = parent != null && parent.isFunction();
536                    if (isFunction) {
537                        g.functionName.put(node, parentName);
538                    } else if (!parentsWithOutgoing.contains(parentId)) {
539                        // Terminal resultset: no column of it feeds anything —
540                        // this is a routine OUTPUT result set (§2.2 boundary).
541                        // Duplicate display names get "#k" by occurrence order.
542                        List<String> dup = terminalNameOccurrences.get(parentName);
543                        String qualified = dup != null && dup.size() > 1
544                                ? parentName + "#" + dup.indexOf(parentId)
545                                : parentName;
546                        g.boundary.put(node, new Endpoint(
547                                EndpointKind.RESULT_SET_COLUMN, qualified, column));
548                        if (!g.terminalShapes.containsKey(qualified)) {
549                            List<String> shape = new ArrayList<String>();
550                            if (parent != null && parent.getColumns() != null) {
551                                for (column c : parent.getColumns()) {
552                                    // non-identifier-compare: RelationRows is dlineage's fixed synthetic column name
553                                    if ("RelationRows".equals(c.getName())
554                                            || "system".equals(c.getSource())) {
555                                        continue;
556                                    }
557                                    shape.add(c.getName());
558                                }
559                            }
560                            g.terminalShapes.put(qualified, shape);
561                        }
562                    }
563                    // Non-terminal, non-function resultsets stay interior.
564                }
565            }
566            return g;
567        }
568
569        static String key(String parentId, String column) {
570            return (parentId == null ? "" : parentId) + ' '
571                    + (column == null ? "" : column);
572        }
573    }
574
575    /** Result of {@link #closeGraph}: summary edges plus the full per-node
576     *  reach map (B3 consumes the latter for call-site actual reach). */
577    static final class Closure {
578        final Map<String, Map<String, PathAttr>> reach;
579        final Set<RoutineSummaryEdge> edges;
580
581        Closure(Map<String, Map<String, PathAttr>> reach,
582                Set<RoutineSummaryEdge> edges) {
583            this.reach = reach;
584            this.edges = edges;
585        }
586    }
587
588    /** B2-compatible view of {@link #closeGraph}: the summary edges only. */
589    static Set<RoutineSummaryEdge> close(RelationGraph g) {
590        return closeGraph(g).edges;
591    }
592
593    /**
594     * Worklist closure: per node, the map boundary-source-key →
595     * {reachable via fdd-only path, reachable via fdr-bearing path,
596     * functions on value paths}. Monotone joins ⇒ convergence.
597     *
598     * <p>Propagation continues THROUGH boundary nodes: a path formal → table
599     * → formal is two summary edges AND the transitive pair, matching the
600     * inline engine's shared-model transitivity.
601     */
602    static Closure closeGraph(RelationGraph g) {
603        Map<String, Map<String, PathAttr>> reach =
604                new HashMap<String, Map<String, PathAttr>>();
605        Deque<String> worklist = new ArrayDeque<String>();
606        for (String node : g.nodes) {
607            if (g.boundary.containsKey(node)) {
608                Map<String, PathAttr> self = new HashMap<String, PathAttr>();
609                PathAttr attr = new PathAttr();
610                attr.valueOnly = true;
611                self.put(node, attr);
612                reach.put(node, self);
613                worklist.push(node);
614            }
615        }
616        while (!worklist.isEmpty()) {
617            String node = worklist.pop();
618            Map<String, PathAttr> nodeReach = reach.get(node);
619            if (nodeReach == null) {
620                continue;
621            }
622            List<String[]> hops = g.out.get(node);
623            if (hops == null) {
624                continue;
625            }
626            for (String[] hop : hops) {
627                String target = hop[0];
628                boolean hopFdr = "1".equals(hop[1]);
629                String targetFunction = g.functionName.get(target);
630                Map<String, PathAttr> targetReach = reach.get(target);
631                if (targetReach == null) {
632                    targetReach = new HashMap<String, PathAttr>();
633                    reach.put(target, targetReach);
634                }
635                boolean changed = false;
636                for (Map.Entry<String, PathAttr> e : nodeReach.entrySet()) {
637                    PathAttr in = e.getValue();
638                    PathAttr outAttr = targetReach.get(e.getKey());
639                    if (outAttr == null) {
640                        outAttr = new PathAttr();
641                        targetReach.put(e.getKey(), outAttr);
642                    }
643                    boolean nvo = in.valueOnly && !hopFdr;
644                    boolean nfr = in.withFdr || hopFdr;
645                    if (nvo && !outAttr.valueOnly) {
646                        outAttr.valueOnly = true;
647                        changed = true;
648                    }
649                    if (nfr && !outAttr.withFdr) {
650                        outAttr.withFdr = true;
651                        changed = true;
652                    }
653                    if (nvo) {
654                        Set<String> fns = new TreeSet<String>(in.functions);
655                        if (targetFunction != null) {
656                            fns.add(targetFunction);
657                        }
658                        if (!outAttr.functions.containsAll(fns)) {
659                            outAttr.functions.addAll(fns);
660                            changed = true;
661                        }
662                    }
663                }
664                if (changed) {
665                    worklist.push(target);
666                }
667            }
668        }
669        Set<RoutineSummaryEdge> edges = new LinkedHashSet<RoutineSummaryEdge>();
670        for (Map.Entry<String, Map<String, PathAttr>> e : reach.entrySet()) {
671            Endpoint target = g.boundary.get(e.getKey());
672            if (target == null) {
673                continue;
674            }
675            for (Map.Entry<String, PathAttr> s : e.getValue().entrySet()) {
676                if (s.getKey().equals(e.getKey())) {
677                    continue; // self-seed
678                }
679                Endpoint source = g.boundary.get(s.getKey());
680                if (source == null) {
681                    continue;
682                }
683                PathAttr attr = s.getValue();
684                if (attr.valueOnly) {
685                    edges.add(new RoutineSummaryEdge(source, target, "fdd",
686                            attr.functions));
687                }
688                if (attr.withFdr) {
689                    edges.add(new RoutineSummaryEdge(source, target, "fdr", null));
690                }
691            }
692        }
693        return new Closure(reach, edges);
694    }
695
696    /**
697     * B3: forward reach from ONE node (a call-site OUT actual) to the graph's
698     * boundary endpoints. Same monotone worklist as {@link #closeGraph}, but
699     * seeded at an arbitrary (typically interior) node — the closure's own
700     * reach map is seeded only at boundaries, so this direction needs its own
701     * pass. Returns boundary endpoint → path attributes.
702     */
703    static Map<Endpoint, PathAttr> forwardReach(RelationGraph g, String seed) {
704        Map<Endpoint, PathAttr> result = new LinkedHashMap<Endpoint, PathAttr>();
705        for (Map.Entry<String, PathAttr> e : forwardReachAll(g, seed).entrySet()) {
706            Endpoint boundary = g.boundary.get(e.getKey());
707            if (boundary != null) {
708                result.put(boundary, e.getValue());
709            }
710        }
711        return result;
712    }
713
714    /** Forward reach over ALL nodes (interior included) — the intra-caller
715     *  bridge for call-to-call composition needs interior locals
716     *  (codex-B3-r1 finding 5). */
717    static Map<String, PathAttr> forwardReachAll(RelationGraph g, String seed) {
718        Map<String, PathAttr> attrs = new HashMap<String, PathAttr>();
719        PathAttr self = new PathAttr();
720        self.valueOnly = true;
721        attrs.put(seed, self);
722        Deque<String> worklist = new ArrayDeque<String>();
723        worklist.push(seed);
724        while (!worklist.isEmpty()) {
725            String node = worklist.pop();
726            PathAttr in = attrs.get(node);
727            List<String[]> hops = g.out.get(node);
728            if (hops == null || in == null) {
729                continue;
730            }
731            for (String[] hop : hops) {
732                String target = hop[0];
733                boolean hopFdr = "1".equals(hop[1]);
734                PathAttr out = attrs.get(target);
735                if (out == null) {
736                    out = new PathAttr();
737                    attrs.put(target, out);
738                }
739                boolean changed = false;
740                boolean nvo = in.valueOnly && !hopFdr;
741                boolean nfr = in.withFdr || hopFdr;
742                if (nvo && !out.valueOnly) {
743                    out.valueOnly = true;
744                    changed = true;
745                }
746                if (nfr && !out.withFdr) {
747                    out.withFdr = true;
748                    changed = true;
749                }
750                if (changed) {
751                    worklist.push(target);
752                }
753            }
754        }
755        return attrs;
756    }
757
758    /** Monotone per-(source,node) path attributes. */
759    static final class PathAttr {
760        boolean valueOnly;
761        boolean withFdr;
762        final Set<String> functions = new TreeSet<String>();
763    }
764
765    // ------------------------------------------------------------------
766    // Support: isolated analysis, identity, raw call sites.
767    // ------------------------------------------------------------------
768
769    private dataflow analyze(String text) {
770        try {
771            Option opt = new Option();
772            opt.setVendor(vendor);
773            opt.setOutput(false);
774            opt.setShowJoin(true);
775            // SHADOW analyzers run identity-first pools: package overloads in
776            // a whole-package slice keep separated formal/cursor pools
777            // (codex-B3-r1 finding 7 — the mode must actually be exercised).
778            opt.setIdentityFirstVariablePools(true);
779            // Base-summary edges currently have no per-edge dynamic-site
780            // provenance. Never flatten an inner dynamic-SQL relationship
781            // into that static edge set: an incomplete LEGACY fold can name
782            // the wrong source/target yet would otherwise look identical to
783            // a proven static relationship during call-site composition.
784            opt.setAnalyzeDynamicSql(false);
785            DataFlowAnalyzer analyzer = new DataFlowAnalyzer(text, opt);
786            analyzer.generateDataFlow();
787            return analyzer.getDataFlow();
788        } catch (RuntimeException ex) {
789            return null;
790        } catch (StackOverflowError err) {
791            return null;
792        }
793    }
794
795    private static RoutineIdentity identityOf(RoutineCatalog.UnitIndex index) {
796        if (!index.definitions.isEmpty()) {
797            return index.definitions.get(0).identity;
798        }
799        if (!index.memberDefinitions.isEmpty()) {
800            return index.memberDefinitions.get(0).identity;
801        }
802        return null;
803    }
804
805    // ------------------------------------------------------------------
806    // B3: call sites with AST actuals + caller-side boundary reach.
807    // ------------------------------------------------------------------
808
809    /** AST-side view of one call site before model reach is attached. */
810    private static final class CallSiteAst {
811        final String rawName;
812        final TCustomSqlStatement stmt;
813        final List<CallSiteRef.Actual> actuals;
814        final gudusoft.gsqlparser.stmt.TInsertSqlStatement enclosingInsert;
815
816        CallSiteAst(String rawName, TCustomSqlStatement stmt,
817                List<CallSiteRef.Actual> actuals,
818                gudusoft.gsqlparser.stmt.TInsertSqlStatement enclosingInsert) {
819            this.rawName = rawName;
820            this.stmt = stmt;
821            this.actuals = actuals;
822            this.enclosingInsert = enclosingInsert;
823        }
824    }
825
826    /** Everything one body walk collects: call sites + RETURN-with-value. */
827    private static final class CollectedBody {
828        final List<CallSiteAst> sites = new ArrayList<CallSiteAst>();
829        boolean returnsValue;
830    }
831
832    /** AST-level facts shared by BOTH CallSiteRef construction paths (the
833     *  reach-enriched extractor path and the AST-only B0 contract path —
834     *  codex-B3-r2 finding 4a): INSERT…EXEC column list / missing-list flag
835     *  and the {@code @ret = EXEC} status target. */
836    private void enrichAstFacts(CallSiteRef ref, CallSiteAst ast) {
837        if (ast.enclosingInsert != null) {
838            if (ast.enclosingInsert.getColumnList() != null
839                    && ast.enclosingInsert.getColumnList().size() > 0) {
840                List<TObjectName> columnNames = new ArrayList<TObjectName>();
841                for (int i = 0; i < ast.enclosingInsert.getColumnList().size(); i++) {
842                    columnNames.add(ast.enclosingInsert.getColumnList()
843                            .getObjectName(i));
844                }
845                ref.setInsertColumnNames(columnNames);
846            } else {
847                // No explicit column list: ordinals are unknowable
848                // without a catalog — the binder reports PARTIAL.
849                ref.setInsertWithoutColumnList(true);
850            }
851        }
852        if (ast.stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) {
853            ref.setReturnStatusTarget(
854                    ((gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) ast.stmt)
855                            .getReturnStatus());
856        }
857    }
858
859    /** Collect the slice's call sites and attach the caller-side reach the
860     *  SHADOW composition step needs (design §2.3): per IN actual, the caller
861     *  boundary endpoints feeding it; per OUT actual, the caller boundary
862     *  endpoints its value reaches downstream. */
863    private List<CallSiteRef> callSites(List<CallSiteAst> asts, dataflow df,
864            RelationGraph g, Closure closure) {
865        List<CallSiteRef> result = new ArrayList<CallSiteRef>();
866        List<table> variables = df.getVariables() == null
867                ? Collections.<table>emptyList() : df.getVariables();
868        List<table> tables = df.getTables() == null
869                ? Collections.<table>emptyList() : df.getTables();
870        for (CallSiteAst ast : asts) {
871            Map<Integer, Set<Endpoint>> inFdd = new LinkedHashMap<Integer, Set<Endpoint>>();
872            Map<Integer, Set<Endpoint>> inFdr = new LinkedHashMap<Integer, Set<Endpoint>>();
873            Map<Integer, Set<Endpoint>> outFdd = new LinkedHashMap<Integer, Set<Endpoint>>();
874            Map<Integer, Set<Endpoint>> outFdr = new LinkedHashMap<Integer, Set<Endpoint>>();
875            Map<Integer, Set<String>> inNodes = new LinkedHashMap<Integer, Set<String>>();
876            Map<Integer, Map<String, Integer>> outReachNodes =
877                    new LinkedHashMap<Integer, Map<String, Integer>>();
878            for (CallSiteRef.Actual actual : ast.actuals) {
879                if (actual.isLiteral() || actual.isDefaultToken()) {
880                    continue; // no source binds (inline parity / declared default)
881                }
882                List<TObjectName> leafs = actual.getBareReference() != null
883                        ? Collections.singletonList(actual.getBareReference())
884                        : actual.getLeafReferences();
885                for (TObjectName leaf : leafs) {
886                    String node = nodeKeyForLeaf(leaf, variables, tables);
887                    if (node == null) {
888                        continue;
889                    }
890                    Set<String> ownNodes = inNodes.get(actual.getPosition());
891                    if (ownNodes == null) {
892                        ownNodes = new LinkedHashSet<String>();
893                        inNodes.put(actual.getPosition(), ownNodes);
894                    }
895                    ownNodes.add(node);
896                    Endpoint selfBoundary = g.boundary.get(node);
897                    if (selfBoundary != null) {
898                        addEndpoint(inFdd, actual.getPosition(), selfBoundary);
899                    }
900                    Map<String, PathAttr> reach = closure.reach.get(node);
901                    if (reach != null) {
902                        for (Map.Entry<String, PathAttr> e : reach.entrySet()) {
903                            if (e.getKey().equals(node)) {
904                                continue;
905                            }
906                            Endpoint source = g.boundary.get(e.getKey());
907                            if (source == null) {
908                                continue;
909                            }
910                            if (e.getValue().valueOnly) {
911                                addEndpoint(inFdd, actual.getPosition(), source);
912                            }
913                            if (e.getValue().withFdr) {
914                                addEndpoint(inFdr, actual.getPosition(), source);
915                            }
916                        }
917                    }
918                    // Forward reach for potential OUT targets: only bare
919                    // actuals qualify (an expression is never a writable
920                    // lvalue — the binder drops those with a diagnostic).
921                    // Interior nodes are kept too: they anchor the
922                    // intra-caller bridge for call-to-call composition.
923                    if (actual.getBareReference() != null) {
924                        Map<String, Integer> nodeFlavors =
925                                outReachNodes.get(actual.getPosition());
926                        if (nodeFlavors == null) {
927                            nodeFlavors = new LinkedHashMap<String, Integer>();
928                            outReachNodes.put(actual.getPosition(), nodeFlavors);
929                        }
930                        for (Map.Entry<String, PathAttr> e
931                                : forwardReachAll(g, node).entrySet()) {
932                            int bits = (e.getValue().valueOnly ? 1 : 0)
933                                    | (e.getValue().withFdr ? 2 : 0);
934                            Integer prior = nodeFlavors.get(e.getKey());
935                            nodeFlavors.put(e.getKey(),
936                                    prior == null ? bits : (prior | bits));
937                            Endpoint boundary = g.boundary.get(e.getKey());
938                            if (boundary == null) {
939                                continue;
940                            }
941                            if (e.getValue().valueOnly) {
942                                addEndpoint(outFdd, actual.getPosition(), boundary);
943                            }
944                            if (e.getValue().withFdr) {
945                                addEndpoint(outFdr, actual.getPosition(), boundary);
946                            }
947                        }
948                    }
949                }
950            }
951            List<Endpoint> insertTargets = insertTargetsOf(ast, tables);
952            int argCount = ast.actuals.size();
953            CallSiteRef ref = new CallSiteRef(
954                    new RoutineSummary.RawCallSite(ast.rawName, argCount),
955                    ast.stmt, ast.actuals, insertTargets,
956                    inFdd, inFdr, outFdd, outFdr);
957            ref.setInNodes(inNodes);
958            ref.setOutReachNodes(outReachNodes);
959            enrichAstFacts(ref, ast);
960            result.add(ref);
961        }
962        return result;
963    }
964
965    private static void addEndpoint(Map<Integer, Set<Endpoint>> map, int position,
966            Endpoint endpoint) {
967        Set<Endpoint> set = map.get(position);
968        if (set == null) {
969            set = new LinkedHashSet<Endpoint>();
970            map.put(position, set);
971        }
972        set.add(endpoint);
973    }
974
975    /** INSERT…EXEC positional targets: the INSERT's explicit column list
976     *  resolved against the analyzed target table. Null when the call is not
977     *  INSERT-consumed or the column list is absent (the binder reports the
978     *  latter as PARTIAL — ordinals are never guessed). */
979    private List<Endpoint> insertTargetsOf(CallSiteAst ast, List<table> tables) {
980        if (ast.enclosingInsert == null
981                || ast.enclosingInsert.getColumnList() == null
982                || ast.enclosingInsert.getColumnList().size() == 0
983                || ast.enclosingInsert.getTargetTable() == null) {
984            return null;
985        }
986        String targetName = ast.enclosingInsert.getTargetTable().toString();
987        String display = targetName;
988        for (table t : tables) {
989            if (t.getName() != null && sameQualifiedTable(targetName, t.getName())) {
990                display = t.getName();
991                break;
992            }
993        }
994        List<Endpoint> targets = new ArrayList<Endpoint>();
995        // #tmp targets are TEMP endpoints — the same classification the
996        // graph builder applies to '#'-prefixed tables (codex-B3-r3 f5).
997        List<String> displayParts = SQLUtil.parseNames(display);
998        String displaySimple = displayParts.isEmpty()
999                ? display : displayParts.get(displayParts.size() - 1);
1000        EndpointKind kind = displaySimple.startsWith("#")
1001                ? EndpointKind.TEMP_COLUMN : EndpointKind.TABLE_COLUMN;
1002        for (int i = 0; i < ast.enclosingInsert.getColumnList().size(); i++) {
1003            targets.add(new Endpoint(kind, display,
1004                    ast.enclosingInsert.getColumnList().getObjectName(i).toString()));
1005        }
1006        return targets;
1007    }
1008
1009    /** Suffix-tolerant qualified table-name comparison (call text vs model
1010     *  display name), segment-by-segment through the canonical facade. */
1011    private boolean sameQualifiedTable(String a, String b) {
1012        List<String> pa = SQLUtil.parseNames(a);
1013        List<String> pb = SQLUtil.parseNames(b);
1014        int n = Math.min(pa.size(), pb.size());
1015        if (n == 0) {
1016            return false;
1017        }
1018        for (int i = 1; i <= n; i++) {
1019            if (!SQLUtil.sameName(vendor, ESQLDataObjectType.dotTable,
1020                    pa.get(pa.size() - i), pb.get(pb.size() - i))) {
1021                return false;
1022            }
1023        }
1024        return true;
1025    }
1026
1027    /** Model node key for one actual leaf reference: a declared variable
1028     *  (formal or local) matched by name, else a qualified table column.
1029     *  Null when the leaf matches nothing the analyzed model knows. */
1030    private String nodeKeyForLeaf(TObjectName leaf, List<table> variables,
1031            List<table> tables) {
1032        String text = leaf.toString();
1033        List<String> parts = SQLUtil.parseNames(text);
1034        String simple = parts.isEmpty() ? text : parts.get(parts.size() - 1);
1035        for (table v : variables) {
1036            if (v.getName() == null) {
1037                continue;
1038            }
1039            // A USE-re-anchored slice qualifies variable display names
1040            // (e.g. "Db.dbo.@v") — match on the LAST segment, not the whole
1041            // name (codex-B3-r1 finding 2).
1042            List<String> varParts = SQLUtil.parseNames(v.getName());
1043            String varSimple = varParts.isEmpty()
1044                    ? v.getName() : varParts.get(varParts.size() - 1);
1045            if (SQLUtil.sameName(vendor, ESQLDataObjectType.dotColumn,
1046                    simple, varSimple)) {
1047                String columnName = v.getColumns() != null && !v.getColumns().isEmpty()
1048                        ? v.getColumns().get(0).getName() : v.getName();
1049                return RelationGraph.key(v.getId(), columnName);
1050            }
1051        }
1052        if (parts.size() >= 2) {
1053            String tablePart = text.substring(0, text.lastIndexOf('.'));
1054            for (table t : tables) {
1055                if (t.getName() != null && sameQualifiedTable(tablePart, t.getName())) {
1056                    return RelationGraph.key(t.getId(), simple);
1057                }
1058            }
1059        }
1060        return null;
1061    }
1062
1063    /** AST-only call sites of a statement text (no model-side reach) — the
1064     *  B0 contract entry of {@link CatalogRoutineCallBinder} binds these.
1065     *  Carries the same AST-level facts as the enriched path (INSERT…EXEC
1066     *  columns, return-status target — codex-B3-r2 finding 4a). */
1067    List<CallSiteRef> callSitesOfStatementText(String text) {
1068        List<CallSiteRef> result = new ArrayList<CallSiteRef>();
1069        for (CallSiteAst ast : collectCallSiteAsts(text).sites) {
1070            CallSiteRef ref = new CallSiteRef(
1071                    new RoutineSummary.RawCallSite(ast.rawName, ast.actuals.size()),
1072                    ast.stmt, ast.actuals, null, null, null, null, null);
1073            enrichAstFacts(ref, ast);
1074            result.add(ref);
1075        }
1076        return result;
1077    }
1078
1079    /** Parse the slice and collect its call-site statements with actuals,
1080     *  plus body-level RETURN-with-value detection. Best-effort: a parse
1081     *  crash leaves the result empty (summaries stay usable; completeness
1082     *  gating happens on the analysis side). */
1083    private CollectedBody collectCallSiteAsts(String text) {
1084        CollectedBody body = new CollectedBody();
1085        List<CallSiteAst> sites = body.sites;
1086        try {
1087            gudusoft.gsqlparser.TGSqlParser parser =
1088                    new gudusoft.gsqlparser.TGSqlParser(vendor);
1089            parser.sqltext = text;
1090            if (parser.parse() != 0) {
1091                return body;
1092            }
1093            Deque<TCustomSqlStatement> stack = new ArrayDeque<TCustomSqlStatement>();
1094            Set<TCustomSqlStatement> visited = Collections.newSetFromMap(
1095                    new java.util.IdentityHashMap<TCustomSqlStatement, Boolean>());
1096            Map<TCustomSqlStatement, gudusoft.gsqlparser.stmt.TInsertSqlStatement>
1097                    insertOf = new java.util.IdentityHashMap<TCustomSqlStatement,
1098                            gudusoft.gsqlparser.stmt.TInsertSqlStatement>();
1099            for (int i = 0; i < parser.sqlstatements.size(); i++) {
1100                stack.push(parser.sqlstatements.get(i));
1101            }
1102            while (!stack.isEmpty()) {
1103                TCustomSqlStatement stmt = stack.pop();
1104                if (!visited.add(stmt)) {
1105                    continue; // getStatements/getBodyStatements can overlap
1106                }
1107                if (stmt instanceof gudusoft.gsqlparser.stmt.TInsertSqlStatement) {
1108                    gudusoft.gsqlparser.stmt.TInsertSqlStatement insert =
1109                            (gudusoft.gsqlparser.stmt.TInsertSqlStatement) stmt;
1110                    if (insert.getExecuteStmt() != null) {
1111                        insertOf.put(insert.getExecuteStmt(), insert);
1112                        stack.push(insert.getExecuteStmt());
1113                    }
1114                } else if (stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) {
1115                    gudusoft.gsqlparser.stmt.mssql.TMssqlExecute exec =
1116                            (gudusoft.gsqlparser.stmt.mssql.TMssqlExecute) stmt;
1117                    // getSqlText() != null is dynamic EXEC('...') — that stays
1118                    // with the dynamic-SQL machinery, not routine binding.
1119                    if (exec.getModuleName() != null && exec.getSqlText() == null) {
1120                        String rawName = exec.getModuleName().toString();
1121                        // sp_executesql/sp_execute_external_script are dynamic
1122                        // executors, not static callees. EXEC @module_var is
1123                        // indirect and cannot bind from the variable spelling.
1124                        if (!isDynamicExecutorName(rawName)
1125                                && !isIndirectMssqlExecute(rawName)) {
1126                            // Recover the ;N group the parser drops from the
1127                            // AST name (numbered procedures — codex-B3-r1 f6).
1128                            String group = RoutineCatalog.numberedGroupAfter(
1129                                    exec.getModuleName());
1130                            if (group != null) {
1131                                rawName = rawName + ";" + group;
1132                            }
1133                            sites.add(new CallSiteAst(rawName,
1134                                    exec, mssqlActuals(exec), insertOf.get(exec)));
1135                        }
1136                    }
1137                } else if (stmt instanceof gudusoft.gsqlparser.stmt.TCallStatement) {
1138                    gudusoft.gsqlparser.stmt.TCallStatement call =
1139                            (gudusoft.gsqlparser.stmt.TCallStatement) stmt;
1140                    if (call.getRoutineName() != null
1141                            && !isDynamicExecutorName(
1142                                    call.getRoutineName().toString())) {
1143                        sites.add(new CallSiteAst(
1144                                call.getRoutineName().toString(), call,
1145                                expressionActuals(call.getArgs()), null));
1146                    }
1147                } else if (stmt instanceof gudusoft.gsqlparser.stmt.oracle.TBasicStmt) {
1148                    // Oracle bare procedure calls inside PL/SQL bodies parse as
1149                    // expression statements, NOT TCallStatement — missing them
1150                    // let a calling summary claim COMPLETE (B2 gap, fixed here).
1151                    gudusoft.gsqlparser.stmt.oracle.TBasicStmt basic =
1152                            (gudusoft.gsqlparser.stmt.oracle.TBasicStmt) stmt;
1153                    TExpression expr = basic.getExpr();
1154                    if (expr != null
1155                            && expr.getExpressionType() == EExpressionType.function_t
1156                            && expr.getFunctionCall() != null
1157                            && expr.getFunctionCall().getFunctionName() != null) {
1158                        TFunctionCall fc = expr.getFunctionCall();
1159                        String rawName = fc.getFunctionName().toString();
1160                        if (!isDynamicExecutorName(rawName)) {
1161                            sites.add(new CallSiteAst(rawName, basic,
1162                                    expressionActuals(fc.getArgs()), null));
1163                        }
1164                    } else if (expr != null
1165                            && expr.getExpressionType() == EExpressionType.simple_object_name_t
1166                            && expr.getObjectOperand() != null) {
1167                        sites.add(new CallSiteAst(expr.getObjectOperand().toString(),
1168                                basic, new ArrayList<CallSiteRef.Actual>(), null));
1169                    }
1170                } else if (stmt instanceof gudusoft.gsqlparser.stmt.mssql.TMssqlReturn) {
1171                    if (((gudusoft.gsqlparser.stmt.mssql.TMssqlReturn) stmt)
1172                            .getReturnExpr() != null) {
1173                        body.returnsValue = true;
1174                    }
1175                } else if (stmt instanceof gudusoft.gsqlparser.stmt.TReturnStmt) {
1176                    if (((gudusoft.gsqlparser.stmt.TReturnStmt) stmt)
1177                            .getExpression() != null) {
1178                        body.returnsValue = true;
1179                    }
1180                }
1181                TStatementList children = stmt.getStatements();
1182                if (children != null) {
1183                    for (int i = 0; i < children.size(); i++) {
1184                        stack.push(children.get(i));
1185                    }
1186                }
1187                if (stmt instanceof gudusoft.gsqlparser.stmt.TBlockSqlStatement) {
1188                    TStatementList bodyStatements =
1189                            ((gudusoft.gsqlparser.stmt.TBlockSqlStatement) stmt)
1190                                    .getBodyStatements();
1191                    if (bodyStatements != null) {
1192                        for (int i = 0; i < bodyStatements.size(); i++) {
1193                            stack.push(bodyStatements.get(i));
1194                        }
1195                    }
1196                }
1197            }
1198        } catch (RuntimeException ex) {
1199            // call collection is best-effort; summaries stay usable
1200        }
1201        return body;
1202    }
1203
1204    /** Dynamic executor APIs carry generated SQL, not a statically named
1205     *  routine call that the summary binder may resolve against user/catalog
1206     *  definitions. Comparison goes through the vendor identifier facade so
1207     *  quoting/case cannot reopen the frozen-call inventory. */
1208    private boolean isDynamicExecutorName(String rawName) {
1209        if (rawName == null) {
1210            return false;
1211        }
1212        if (vendor == EDbVendor.dbvmssql) {
1213            List<String> parts = SQLUtil.parseNames(rawName);
1214            String simple = parts.isEmpty()
1215                    ? rawName : parts.get(parts.size() - 1);
1216            return SQLUtil.sameName(vendor, ESQLDataObjectType.dotProcedure,
1217                    simple, "sp_executesql")
1218                    || SQLUtil.sameName(vendor,
1219                            ESQLDataObjectType.dotProcedure, simple,
1220                            "sp_execute_external_script");
1221        }
1222        if (vendor == EDbVendor.dbvoracle) {
1223            return SQLUtil.compareIdentifier(vendor,
1224                    ESQLDataObjectType.dotProcedure,
1225                    rawName, "DBMS_SQL.PARSE")
1226                    || SQLUtil.compareIdentifier(vendor,
1227                            ESQLDataObjectType.dotProcedure,
1228                            rawName, "SYS.DBMS_SQL.PARSE");
1229        }
1230        return false;
1231    }
1232
1233    /** SQL Server's unquoted {@code EXEC @name} is an indirect procedure
1234     *  invocation. The variable spelling is not the callee identity (while
1235     *  {@code EXEC [@name]} may legitimately call a quoted static routine). */
1236    private static boolean isIndirectMssqlExecute(String rawName) {
1237        return rawName != null && rawName.trim().startsWith("@");
1238    }
1239
1240    /** Actuals of a T-SQL EXEC: named/positional, OUTPUT flag, value shape. */
1241    private List<CallSiteRef.Actual> mssqlActuals(
1242            gudusoft.gsqlparser.stmt.mssql.TMssqlExecute exec) {
1243        List<CallSiteRef.Actual> actuals = new ArrayList<CallSiteRef.Actual>();
1244        if (exec.getParameters() == null) {
1245            return actuals;
1246        }
1247        for (int i = 0; i < exec.getParameters().size(); i++) {
1248            gudusoft.gsqlparser.nodes.TExecParameter param =
1249                    exec.getParameters().getExecParameter(i);
1250            if (param == null) {
1251                continue;
1252            }
1253            String named = param.getParameterName() == null
1254                    ? null : param.getParameterName().toString();
1255            boolean out = param.getParameterMode() == TBaseType.parameter_mode_out
1256                    || param.getParameterMode() == TBaseType.parameter_mode_output;
1257            actuals.add(actualOf(i, named, out, param.getParameterValue()));
1258        }
1259        return actuals;
1260    }
1261
1262    /** Actuals of an expression-list call (Oracle CALL / bare PL/SQL call):
1263     *  positional, with {@code formal => value} named notation unwrapped. */
1264    private List<CallSiteRef.Actual> expressionActuals(
1265            gudusoft.gsqlparser.nodes.TExpressionList args) {
1266        List<CallSiteRef.Actual> actuals = new ArrayList<CallSiteRef.Actual>();
1267        if (args == null) {
1268            return actuals;
1269        }
1270        for (int i = 0; i < args.size(); i++) {
1271            TExpression arg = args.getExpression(i);
1272            if (arg == null) {
1273                continue;
1274            }
1275            String named = null;
1276            TExpression value = arg;
1277            if (arg.getExpressionType() == EExpressionType.ref_arrow_t
1278                    && arg.getLeftOperand() != null && arg.getRightOperand() != null) {
1279                named = arg.getLeftOperand().toString();
1280                value = arg.getRightOperand();
1281            }
1282            // PL/SQL has no call-site OUT marker; the declared mode decides.
1283            actuals.add(actualOf(i, named, false, value));
1284        }
1285        return actuals;
1286    }
1287
1288    /** Classify one actual value expression: literal / bare reference /
1289     *  expression with leaf fan-in. */
1290    private CallSiteRef.Actual actualOf(int position, String named, boolean out,
1291            TExpression value) {
1292        if (value == null) {
1293            return new CallSiteRef.Actual(position, named, out, null, null, null, false);
1294        }
1295        EExpressionType type = value.getExpressionType();
1296        // Bare keyword actuals parse as source tokens: DEFAULT means "use
1297        // the declared default" (distinct kind — compatibility requires
1298        // one); NULL is a plain literal (codex-B3-r2 finding 11).
1299        if (type == EExpressionType.simple_source_token_t) {
1300            String token = value.toString().trim();
1301            if ("DEFAULT".equalsIgnoreCase(token)) { // non-identifier-compare: DEFAULT keyword token
1302                return new CallSiteRef.Actual(position, named, out, null, null, null,
1303                        false, true);
1304            }
1305            if ("NULL".equalsIgnoreCase(token)) { // non-identifier-compare: NULL keyword token
1306                return new CallSiteRef.Actual(position, named, out, null, null, null,
1307                        true);
1308            }
1309        }
1310        if (isCompileTimeLiteral(value)) {
1311            return new CallSiteRef.Actual(position, named, out, null, null, null, true);
1312        }
1313        if (type == EExpressionType.simple_object_name_t
1314                && value.getObjectOperand() != null) {
1315            return new CallSiteRef.Actual(position, named, out,
1316                    value.getObjectOperand(), null, null, false);
1317        }
1318        final List<TObjectName> leafs = new ArrayList<TObjectName>();
1319        final Set<TObjectName> functionNames = Collections.newSetFromMap(
1320                new java.util.IdentityHashMap<TObjectName, Boolean>());
1321        value.acceptChildren(new TParseTreeVisitor() {
1322            @Override
1323            public void preVisit(TFunctionCall fc) {
1324                if (fc.getFunctionName() != null) {
1325                    functionNames.add(fc.getFunctionName());
1326                }
1327            }
1328
1329            @Override
1330            public void preVisit(TObjectName name) {
1331                leafs.add(name);
1332            }
1333        });
1334        List<TObjectName> filtered = new ArrayList<TObjectName>();
1335        for (TObjectName leaf : leafs) {
1336            if (!functionNames.contains(leaf)) {
1337                filtered.add(leaf);
1338            }
1339        }
1340        return new CallSiteRef.Actual(position, named, out, null, filtered,
1341                value.toString(), false);
1342    }
1343
1344    /** Compile-time literal shapes: plain constants and sign-prefixed
1345     *  constants. Deliberately NOT {@code null_t} — that is the IS NULL
1346     *  PREDICATE type, whose operand fan-in must survive (codex-B3-r2
1347     *  finding 11); a bare NULL actual is a source token handled above. */
1348    private static boolean isCompileTimeLiteral(TExpression value) {
1349        EExpressionType type = value.getExpressionType();
1350        if (type == EExpressionType.simple_constant_t) {
1351            return true;
1352        }
1353        if (type == EExpressionType.unary_minus_t
1354                || type == EExpressionType.unary_plus_t) {
1355            TExpression operand = value.getRightOperand() != null
1356                    ? value.getRightOperand() : value.getLeftOperand();
1357            return operand != null && operand.getExpressionType()
1358                    == EExpressionType.simple_constant_t;
1359        }
1360        return false;
1361    }
1362
1363    private boolean containsDynamicSql(String text) {
1364        // Cheap lexical screen for the PARTIAL flag only (never used for
1365        // binding): EXEC(<expr>) / sp_executesql / EXECUTE IMMEDIATE.
1366        String lower = text.toLowerCase(Locale.ROOT);
1367        return lower.contains("sp_executesql")
1368                || lower.contains("execute immediate")
1369                || lower.contains("dbms_sql.parse")
1370                || lower.matches("(?s).*\\bexec(ute)?\\s*\\(.*");
1371    }
1372}