001package gudusoft.gsqlparser.resolver2.binding;
002
003import gudusoft.gsqlparser.EExpressionType;
004import gudusoft.gsqlparser.EJoinType;
005import gudusoft.gsqlparser.ESqlClause;
006import gudusoft.gsqlparser.ETableSource;
007import gudusoft.gsqlparser.TCustomSqlStatement;
008import gudusoft.gsqlparser.TStatementList;
009import gudusoft.gsqlparser.nodes.TJoinExpr;
010import gudusoft.gsqlparser.nodes.TObjectName;
011import gudusoft.gsqlparser.nodes.TParseTreeNode;
012import gudusoft.gsqlparser.nodes.TParseTreeVisitor;
013import gudusoft.gsqlparser.nodes.TResultColumn;
014import gudusoft.gsqlparser.nodes.TResultColumnList;
015import gudusoft.gsqlparser.nodes.TTable;
016import gudusoft.gsqlparser.resolver2.ColumnLevel;
017import gudusoft.gsqlparser.resolver2.ScopeBuildResult;
018import gudusoft.gsqlparser.resolver2.TSQLResolver2;
019import gudusoft.gsqlparser.resolver2.TSQLResolverConfig;
020import gudusoft.gsqlparser.resolver2.model.AmbiguousColumnSource;
021import gudusoft.gsqlparser.resolver2.model.ColumnSource;
022import gudusoft.gsqlparser.resolver2.model.ResolutionContext;
023import gudusoft.gsqlparser.resolver2.model.ResolutionResult;
024import gudusoft.gsqlparser.resolver2.namespace.CTENamespace;
025import gudusoft.gsqlparser.resolver2.namespace.INamespace;
026import gudusoft.gsqlparser.resolver2.namespace.MetadataState;
027import gudusoft.gsqlparser.resolver2.namespace.SubqueryNamespace;
028import gudusoft.gsqlparser.resolver2.namespace.UnionNamespace;
029import gudusoft.gsqlparser.resolver2.scope.IScope;
030import gudusoft.gsqlparser.stmt.TSelectSqlStatement;
031
032import java.util.ArrayDeque;
033import java.util.ArrayList;
034import java.util.Collections;
035import java.util.Deque;
036import java.util.HashSet;
037import java.util.IdentityHashMap;
038import java.util.List;
039import java.util.Locale;
040import java.util.Set;
041
042/**
043 * Slice S5: orchestrator that walks the recorded binding trace
044 * ({@link ResolutionContext#getColumnResolutionResult}) once after
045 * iterative resolution converges and converts {@link ResolutionResult}s
046 * into a public {@link BindingResult}.
047 *
048 * <p>The class is {@code public} because {@link TSQLResolver2} (in the
049 * parent {@code resolver2} package) instantiates it directly. The static
050 * invocation counter accessors remain package-private — they are
051 * test-only.</p>
052 *
053 * <p>Plan §5.6.1 critical clarification — the post-pass MUST NOT perform
054 * name binding. It only reads resolver2's final state and decides which
055 * stable, public {@link BindingDiagnostic} to surface. In particular it
056 * never re-invokes {@code NameResolver.resolve(...)} or mutates AST
057 * state.</p>
058 *
059 * <p>This slice ships the first three diagnostic codes ({@code
060 * UNKNOWN_COLUMN}, {@code AMBIGUOUS_COLUMN}, {@code
061 * UNBOUND_COLUMN_REFERENCE}). Strict-mode codes ({@code UNKNOWN_TABLE},
062 * {@code UNKNOWN_ALIAS}, {@code CATALOG_METADATA_UNAVAILABLE}) and clause
063 * attribution land in S6; deeper codes (CTE / subquery output, set-op
064 * arity, USING / NATURAL, DML) land in S8–S13.</p>
065 */
066public final class BindingDiagnosticPostPass {
067
068    /**
069     * Static invocation counter used by {@code BindingPostPassInvocationTest}
070     * to confirm the post-pass runs at most once per resolver call regardless
071     * of how many iterations the resolver loop executed (plan §13 risk row).
072     * Test-only — not part of the public surface.
073     */
074    private static volatile int invocationCount = 0;
075
076    static int getInvocationCount() {
077        return invocationCount;
078    }
079
080    static void resetInvocationCounter() {
081        invocationCount = 0;
082    }
083
084    private final TSQLResolver2 resolver;
085    private final TSQLResolverConfig config;
086    private BindingClauseMapper mapper = BindingClauseMapper.empty();
087
088    public BindingDiagnosticPostPass(TSQLResolver2 resolver, TSQLResolverConfig config) {
089        this.resolver = resolver;
090        this.config = config;
091    }
092
093    /**
094     * Run the post-pass exactly once and return the populated
095     * {@link BindingResult}.
096     *
097     * <p>Always non-null. Returns {@link BindingResult#empty()} when the
098     * inputs do not yet satisfy the minimum invariants (no scope build
099     * result, no resolution context, trace not enabled) — never throws.</p>
100     */
101    public BindingResult run() {
102        invocationCount++;
103
104        ScopeBuildResult sbr = resolver != null ? resolver.getScopeBuildResult() : null;
105        ResolutionContext ctx = resolver != null ? resolver.getContext() : null;
106        if (sbr == null || ctx == null || !ctx.isBindingTraceEnabled()) {
107            return BindingResult.empty();
108        }
109
110        mapper = BindingClauseMapper.of(resolver.getStatements());
111
112        boolean includeSuccessful = config != null
113            && config.isBindingIncludeSuccessfulReferences();
114
115        List<BindingDiagnostic> diagnostics = new ArrayList<BindingDiagnostic>();
116        List<BindingReference> references = new ArrayList<BindingReference>();
117
118        collectSetOperationDiagnostics(diagnostics);
119        collectJoinDiagnostics(sbr, diagnostics);
120
121        List<TObjectName> refs = sbr.getAllColumnReferences();
122        if (refs == null || refs.isEmpty()) {
123            return diagnostics.isEmpty() ? BindingResult.empty()
124                : BindingResult.of(diagnostics, references);
125        }
126
127        // Identity-keyed dedup so re-emitted diagnostics for the same
128        // TObjectName do not stack (e.g., two passes touching the same
129        // reference). The trace itself is identity-keyed for the same
130        // reason — see ResolutionContext#columnResolutionResults.
131        IdentityHashMap<TObjectName, BindingDiagnostic> seenDiagnostics =
132            new IdentityHashMap<TObjectName, BindingDiagnostic>();
133        IdentityHashMap<TObjectName, BindingReference> seenReferences =
134            new IdentityHashMap<TObjectName, BindingReference>();
135
136        for (TObjectName ref : refs) {
137            if (ref == null) continue;
138
139            BindingSkipReason skip = ctx.getColumnSkipReason(ref);
140            if (skip != null) {
141                continue;
142            }
143
144            // S13: DML target/definition columns are NOT user references — they
145            // declare WHERE the write goes, not what it reads. Skip silently and
146            // tag the trace so consumers reading ResolutionContext directly can
147            // see intent (plan §10.5 / §13 failure-handling row).
148            if (isDmlTargetColumn(ref)) {
149                ctx.recordColumnSkipReason(ref, BindingSkipReason.TARGET_COLUMN);
150                continue;
151            }
152
153            ResolutionResult result = ctx.getColumnResolutionResult(ref);
154            if (result == null) {
155                // No recorded resolution. This is normal for synthetic clones
156                // (already filtered above via SYNTHETIC_STAR_CLONE) and for
157                // references the resolver intentionally skipped. The S3
158                // coverage gate ensures every other reference is recorded;
159                // we trust that gate here.
160                continue;
161            }
162
163            if (!seenDiagnostics.containsKey(ref)) {
164                BindingDiagnostic setOpOutputDiagnostic = buildSetOperationOutputDiagnostic(ref, sbr);
165                if (setOpOutputDiagnostic != null) {
166                    seenDiagnostics.put(ref, setOpOutputDiagnostic);
167                    diagnostics.add(setOpOutputDiagnostic);
168
169                    if (includeSuccessful && !seenReferences.containsKey(ref)) {
170                        BindingReference br = buildReference(ref, sbr, /* bound */ false);
171                        if (br != null) {
172                            seenReferences.put(ref, br);
173                            references.add(br);
174                        }
175                    }
176                    continue;
177                }
178                BindingDiagnostic cteOutputDiagnostic = buildCteOutputDiagnostic(ref, sbr);
179                if (cteOutputDiagnostic != null) {
180                    seenDiagnostics.put(ref, cteOutputDiagnostic);
181                    diagnostics.add(cteOutputDiagnostic);
182
183                    if (includeSuccessful && !seenReferences.containsKey(ref)) {
184                        BindingReference br = buildReference(ref, sbr, /* bound */ false);
185                        if (br != null) {
186                            seenReferences.put(ref, br);
187                            references.add(br);
188                        }
189                    }
190                    continue;
191                }
192                BindingDiagnostic subqueryOutputDiagnostic = buildSubqueryOutputDiagnostic(ref, sbr);
193                if (subqueryOutputDiagnostic != null) {
194                    seenDiagnostics.put(ref, subqueryOutputDiagnostic);
195                    diagnostics.add(subqueryOutputDiagnostic);
196
197                    if (includeSuccessful && !seenReferences.containsKey(ref)) {
198                        BindingReference br = buildReference(ref, sbr, /* bound */ false);
199                        if (br != null) {
200                            seenReferences.put(ref, br);
201                            references.add(br);
202                        }
203                    }
204                    continue;
205                }
206            }
207
208            if (result.isExactMatch()) {
209                // S16c (plan §5.6.2): EXACT_MATCH means resolver2 picked one
210                // SQL source for this reference, but it does NOT mean catalog
211                // metadata has confirmed the column actually lives on the
212                // selected source. Read the resolver2-selected namespace from
213                // the recorded ResolutionResult and ask the catalog only.
214                // We never call NameResolver.resolve(...) again, never search
215                // the SQL scope, and never mutate AST state.
216                if (!seenDiagnostics.containsKey(ref)) {
217                    BindingDiagnostic exactMatchDiag =
218                        verifyExactMatchAgainstCatalog(ref, result, sbr);
219                    if (exactMatchDiag != null) {
220                        seenDiagnostics.put(ref, exactMatchDiag);
221                        diagnostics.add(exactMatchDiag);
222                        if (includeSuccessful && !seenReferences.containsKey(ref)) {
223                            BindingReference br = buildReference(ref, sbr, /* bound */ false);
224                            if (br != null) {
225                                seenReferences.put(ref, br);
226                                references.add(br);
227                            }
228                        }
229                        continue;
230                    }
231                }
232                // In strict mode, even an exact-match resolution may be
233                // suspect when the backing namespace has unreliable metadata
234                // (e.g. resolver inferred a 0.80-confidence match for a table
235                // that is NOT_FOUND_IN_CATALOG or METADATA_UNAVAILABLE).
236                if (config != null && config.isBindingStrictCatalogValidation()
237                        && !seenDiagnostics.containsKey(ref)) {
238                    BindingDiagnostic strictDiag = buildStrictModeNamespaceDiagnostic(ref, sbr);
239                    if (strictDiag != null) {
240                        seenDiagnostics.put(ref, strictDiag);
241                        diagnostics.add(strictDiag);
242                        continue;
243                    }
244                }
245                if (includeSuccessful && !seenReferences.containsKey(ref)) {
246                    BindingReference br = buildReference(ref, sbr, /* bound */ true);
247                    if (br != null) {
248                        seenReferences.put(ref, br);
249                        references.add(br);
250                    }
251                }
252                continue;
253            }
254
255            if (seenDiagnostics.containsKey(ref)) {
256                continue;
257            }
258
259            BindingDiagnostic diagnostic = null;
260            if (result.isAmbiguous()) {
261                diagnostic = buildAmbiguousDiagnostic(ref, result, sbr);
262            } else if (result.isNotFound()) {
263                diagnostic = buildNotFoundDiagnostic(ref, ctx, sbr);
264            }
265
266            if (diagnostic != null) {
267                seenDiagnostics.put(ref, diagnostic);
268                diagnostics.add(diagnostic);
269
270                if (includeSuccessful && !seenReferences.containsKey(ref)) {
271                    BindingReference br = buildReference(ref, sbr, /* bound */ false);
272                    if (br != null) {
273                        seenReferences.put(ref, br);
274                        references.add(br);
275                    }
276                }
277            }
278        }
279
280        if (diagnostics.isEmpty() && references.isEmpty()) {
281            return BindingResult.empty();
282        }
283        return BindingResult.of(diagnostics, references);
284    }
285
286    // ===== AMBIGUOUS =====
287
288    private BindingDiagnostic buildAmbiguousDiagnostic(TObjectName ref,
289                                                       ResolutionResult result,
290                                                       ScopeBuildResult sbr) {
291        AmbiguousColumnSource ambiguous = result.getAmbiguousSource();
292        List<String> candidates = candidatesOf(ambiguous);
293        String text = ref.toString();
294
295        BindingDiagnosticSeverity severity = severityFor(BindingDiagnosticCode.AMBIGUOUS_COLUMN);
296        BindingDiagnostic.Builder b = BindingDiagnosticBuilder
297            .forCode(BindingDiagnosticCode.AMBIGUOUS_COLUMN)
298            .severity(severity)
299            .message("Column " + text + " is ambiguous between "
300                + (candidates.isEmpty() ? "multiple sources" : candidates) + ".")
301            .objectNameText(text)
302            .candidates(candidates)
303            .site(siteFor(ref))
304            .statement(statementFor(ref, sbr));
305        return b.build();
306    }
307
308    private static List<String> candidatesOf(AmbiguousColumnSource ambiguous) {
309        if (ambiguous == null) {
310            return Collections.emptyList();
311        }
312        List<String> out = new ArrayList<String>();
313        for (ColumnSource cs : ambiguous.getCandidates()) {
314            if (cs == null) continue;
315            String label = describeCandidate(cs, ambiguous.getColumnName());
316            if (label != null) {
317                out.add(label);
318            }
319        }
320        return out;
321    }
322
323    private static String describeCandidate(ColumnSource cs, String columnName) {
324        if (cs == null) return null;
325        String tableLabel = null;
326        if (cs.getFinalTable() != null && cs.getFinalTable().getName() != null) {
327            tableLabel = cs.getFinalTable().getName().toString();
328        } else if (cs.getSourceNamespace() != null) {
329            tableLabel = cs.getSourceNamespace().getDisplayName();
330        }
331        String exposed = cs.getExposedName();
332        String name = (exposed != null && !exposed.isEmpty()) ? exposed : columnName;
333        if (tableLabel == null || tableLabel.isEmpty()) {
334            return name;
335        }
336        return tableLabel + "." + name;
337    }
338
339    // ===== SET operations =====
340
341    private void collectSetOperationDiagnostics(final List<BindingDiagnostic> diagnostics) {
342        if (diagnostics == null) return;
343        TStatementList statements = resolver != null ? resolver.getStatements() : null;
344        if (statements == null) return;
345
346        final Set<TSelectSqlStatement> covered =
347            Collections.newSetFromMap(new IdentityHashMap<TSelectSqlStatement, Boolean>());
348        for (int i = 0; i < statements.size(); i++) {
349            TCustomSqlStatement stmt = statements.get(i);
350            if (stmt == null) continue;
351            try {
352                stmt.acceptChildren(new TParseTreeVisitor() {
353                    @Override
354                    public void preVisit(TSelectSqlStatement node) {
355                        collectSetOperationDiagnostic(node, diagnostics, covered);
356                    }
357                });
358            } catch (Throwable ignore) {
359                // Defensive: set-operation diagnostics must never fail the post-pass.
360            }
361        }
362    }
363
364    private void collectSetOperationDiagnostic(TSelectSqlStatement stmt,
365                                               List<BindingDiagnostic> diagnostics,
366                                               Set<TSelectSqlStatement> covered) {
367        if (stmt == null || !stmt.isCombinedQuery() || covered.contains(stmt)) {
368            return;
369        }
370
371        SetOperationShape shape = analyzeSetOperationShape(stmt);
372        if (!shape.authoritative || !shape.hasMismatch()) {
373            return;
374        }
375
376        diagnostics.add(buildSetOperationArityDiagnostic(stmt, shape));
377        covered.addAll(collectCombinedSetOperationNodes(stmt));
378    }
379
380    private SetOperationShape analyzeSetOperationShape(TSelectSqlStatement stmt) {
381        List<TSelectSqlStatement> branches = flattenSetOperationBranches(stmt);
382        List<Integer> counts = new ArrayList<Integer>();
383        if (branches.size() < 2) {
384            return SetOperationShape.nonAuthoritative(counts);
385        }
386
387        for (TSelectSqlStatement branch : branches) {
388            if (branch == null || branch.isCombinedQuery()) {
389                return SetOperationShape.nonAuthoritative(counts);
390            }
391            TResultColumnList selectList = branch.getResultColumnList();
392            if (selectList == null || containsStarResultColumn(selectList)) {
393                return SetOperationShape.nonAuthoritative(counts);
394            }
395            counts.add(Integer.valueOf(selectList.size()));
396        }
397
398        return SetOperationShape.authoritative(counts);
399    }
400
401    private static List<TSelectSqlStatement> flattenSetOperationBranches(TSelectSqlStatement stmt) {
402        List<TSelectSqlStatement> branches = new ArrayList<TSelectSqlStatement>();
403        if (stmt == null) {
404            return branches;
405        }
406
407        Deque<TSelectSqlStatement> stack = new ArrayDeque<TSelectSqlStatement>();
408        stack.push(stmt);
409        while (!stack.isEmpty()) {
410            TSelectSqlStatement current = stack.pop();
411            if (current == null) continue;
412            if (current.isCombinedQuery()) {
413                if (current.getRightStmt() != null) {
414                    stack.push(current.getRightStmt());
415                }
416                if (current.getLeftStmt() != null) {
417                    stack.push(current.getLeftStmt());
418                }
419            } else {
420                branches.add(current);
421            }
422        }
423        return branches;
424    }
425
426    private static Set<TSelectSqlStatement> collectCombinedSetOperationNodes(TSelectSqlStatement stmt) {
427        Set<TSelectSqlStatement> nodes =
428            Collections.newSetFromMap(new IdentityHashMap<TSelectSqlStatement, Boolean>());
429        if (stmt == null) {
430            return nodes;
431        }
432
433        Deque<TSelectSqlStatement> stack = new ArrayDeque<TSelectSqlStatement>();
434        stack.push(stmt);
435        while (!stack.isEmpty()) {
436            TSelectSqlStatement current = stack.pop();
437            if (current == null || !current.isCombinedQuery() || !nodes.add(current)) {
438                continue;
439            }
440            if (current.getRightStmt() != null) {
441                stack.push(current.getRightStmt());
442            }
443            if (current.getLeftStmt() != null) {
444                stack.push(current.getLeftStmt());
445            }
446        }
447        return nodes;
448    }
449
450    private static boolean containsStarResultColumn(TResultColumnList selectList) {
451        if (selectList == null) {
452            return true;
453        }
454        for (int i = 0; i < selectList.size(); i++) {
455            TResultColumn resultColumn = selectList.getResultColumn(i);
456            if (isStarResultColumn(resultColumn)) {
457                return true;
458            }
459        }
460        return false;
461    }
462
463    private static boolean isStarResultColumn(TResultColumn resultColumn) {
464        if (resultColumn == null) {
465            return false;
466        }
467
468        // Star modifiers such as BigQuery SELECT * EXCEPT/REPLACE still have
469        // non-authoritative expanded output for S11 arity and missing-output
470        // diagnostics. Treat those as star-derived even when the full result
471        // column text no longer equals "*" or "t.*".
472        if (resultColumn.getExceptColumnList() != null
473                || (resultColumn.getReplaceExprAsIdentifiers() != null
474                    && !resultColumn.getReplaceExprAsIdentifiers().isEmpty())
475                || (resultColumn.getExprAsIdentifiers() != null
476                    && !resultColumn.getExprAsIdentifiers().isEmpty())) {
477            return true;
478        }
479
480        if (resultColumn.getExpr() != null
481                && resultColumn.getExpr().getExpressionType() == EExpressionType.simple_object_name_t
482                && resultColumn.getExpr().getObjectOperand() != null) {
483            String starText = resultColumn.getExpr().getObjectOperand().toString();
484            if (starText != null) {
485                starText = starText.trim();
486                if ("*".equals(starText) || starText.endsWith(".*")) {
487                    return true;
488                }
489            }
490        }
491
492        String text = resultColumn.toString();
493        if (text == null) {
494            return false;
495        }
496        text = text.trim();
497        return "*".equals(text) || text.endsWith(".*");
498    }
499
500    private BindingDiagnostic buildSetOperationArityDiagnostic(TSelectSqlStatement stmt,
501                                                               SetOperationShape shape) {
502        String label = setOperationLabel(stmt);
503        int expected = shape.counts.get(0).intValue();
504        int mismatchIndex = shape.firstMismatchIndex();
505        int actual = shape.counts.get(mismatchIndex).intValue();
506
507        return BindingDiagnosticBuilder
508            .forCode(BindingDiagnosticCode.SET_OPERATION_ARITY_MISMATCH)
509            .severity(severityFor(BindingDiagnosticCode.SET_OPERATION_ARITY_MISMATCH))
510            .message("Set operation " + label
511                + " has branch column count mismatch: branch 1 has "
512                + expected + " column(s), branch " + (mismatchIndex + 1)
513                + " has " + actual + " column(s).")
514            .objectNameText(label)
515            .candidates(branchCountCandidates(shape.counts))
516            .site(setOperationSite(label))
517            .statement(stmt)
518            .build();
519    }
520
521    private static List<String> branchCountCandidates(List<Integer> counts) {
522        if (counts == null || counts.isEmpty()) {
523            return Collections.emptyList();
524        }
525        List<String> out = new ArrayList<String>();
526        for (int i = 0; i < counts.size(); i++) {
527            out.add("branch " + (i + 1) + ": " + counts.get(i));
528        }
529        return out;
530    }
531
532    private static BindingReferenceSite setOperationSite(String label) {
533        return BindingReferenceSite.builder()
534            .clause(BindingClause.OTHER)
535            .referenceText(label == null || label.isEmpty() ? "SET OPERATION" : label)
536            .build();
537    }
538
539    private static String setOperationLabel(TSelectSqlStatement stmt) {
540        if (stmt == null || stmt.getSetOperatorType() == null) {
541            return "SET OPERATION";
542        }
543        String type = stmt.getSetOperatorType().name();
544        if (type == null || "none".equals(type)) {
545            return "SET OPERATION";
546        }
547        String label = type.toUpperCase(Locale.ROOT);
548        if (stmt.isAll()) {
549            label += " ALL";
550        } else if (stmt.isSetOpDistinct()) {
551            label += " DISTINCT";
552        }
553        return label;
554    }
555
556    private static final class SetOperationShape {
557        private final boolean authoritative;
558        private final List<Integer> counts;
559
560        private SetOperationShape(boolean authoritative, List<Integer> counts) {
561            this.authoritative = authoritative;
562            this.counts = counts != null
563                ? Collections.unmodifiableList(new ArrayList<Integer>(counts))
564                : Collections.<Integer>emptyList();
565        }
566
567        static SetOperationShape authoritative(List<Integer> counts) {
568            return new SetOperationShape(true, counts);
569        }
570
571        static SetOperationShape nonAuthoritative(List<Integer> counts) {
572            return new SetOperationShape(false, counts);
573        }
574
575        boolean hasMismatch() {
576            return firstMismatchIndex() >= 0;
577        }
578
579        int firstMismatchIndex() {
580            if (!authoritative || counts.size() < 2) {
581                return -1;
582            }
583            int expected = counts.get(0).intValue();
584            for (int i = 1; i < counts.size(); i++) {
585                if (counts.get(i).intValue() != expected) {
586                    return i;
587                }
588            }
589            return -1;
590        }
591    }
592
593    // ===== JOIN diagnostics =====
594
595    private void collectJoinDiagnostics(ScopeBuildResult sbr,
596                                        List<BindingDiagnostic> diagnostics) {
597        if (sbr == null || diagnostics == null) return;
598        collectUsingColumnDiagnostics(sbr, diagnostics);
599        collectNaturalJoinDiagnostics(diagnostics);
600    }
601
602    private void collectUsingColumnDiagnostics(final ScopeBuildResult sbr,
603                                               final List<BindingDiagnostic> diagnostics) {
604        TStatementList statements = resolver != null ? resolver.getStatements() : null;
605        if (statements == null) return;
606        final Set<TJoinExpr> seen = Collections.newSetFromMap(new IdentityHashMap<TJoinExpr, Boolean>());
607        for (int i = 0; i < statements.size(); i++) {
608            TCustomSqlStatement stmt = statements.get(i);
609            if (stmt == null) continue;
610            try {
611                stmt.acceptChildren(new TParseTreeVisitor() {
612                    @Override
613                    public void preVisit(TJoinExpr node) {
614                        if (node == null || !seen.add(node)
615                                || node.getUsingColumns() == null
616                                || node.getUsingColumns().size() == 0) {
617                            return;
618                        }
619                        Set<TTable> leftTables = collectInputTables(node.getLeftTable());
620                        Set<TTable> rightTables = collectInputTables(node.getRightTable());
621                        if (leftTables.isEmpty() || rightTables.isEmpty()) return;
622                        for (int c = 0; c < node.getUsingColumns().size(); c++) {
623                            TObjectName col = node.getUsingColumns().getObjectName(c);
624                            String name = usingColumnName(col);
625                            if (name == null) continue;
626                            Boolean leftPresent = inputExposesColumn(sbr, leftTables, name);
627                            Boolean rightPresent = inputExposesColumn(sbr, rightTables, name);
628                            // Catalog-honesty rule: emit only when both JOIN
629                            // inputs have authoritative metadata. For a
630                            // composite left input, one table exposing the
631                            // column is sufficient: SQL visibility is over the
632                            // join input, not every physical table beneath it.
633                            if (leftPresent == null || rightPresent == null) continue;
634                            if (leftPresent.booleanValue() && rightPresent.booleanValue()) continue;
635                            diagnostics.add(BindingDiagnosticBuilder
636                                .forCode(BindingDiagnosticCode.USING_COLUMN_NOT_COMMON)
637                                .severity(severityFor(BindingDiagnosticCode.USING_COLUMN_NOT_COMMON))
638                                .message("USING column " + name
639                                    + " is not common to both JOIN inputs.")
640                                .objectNameText(col != null ? col.toString() : name)
641                                .site(joinSite(col != null ? col.toString() : name))
642                                .statement(null)
643                                .build());
644                        }
645                    }
646                });
647            } catch (Throwable ignore) {
648                // Defensive: join diagnostics must never fail the post-pass.
649            }
650        }
651    }
652
653    /**
654     * @return TRUE when at least one table in the input authoritatively exposes
655     * the column, FALSE when all authoritative tables lack it, null when the
656     * input has only unavailable/ambiguous metadata or mixed unavailable misses.
657     */
658    private static Boolean inputExposesColumn(ScopeBuildResult sbr,
659                                             Set<TTable> tables,
660                                             String columnName) {
661        boolean sawAuthoritativeAbsent = false;
662        boolean sawUnavailable = false;
663        for (TTable table : tables) {
664            if (table == null) {
665                sawUnavailable = true;
666                continue;
667            }
668            INamespace ns = sbr.getNamespaceForTable(table);
669            ColumnAuthority authority = BindingMetadataAuthority.lookup(ns, columnName);
670            if (authority == ColumnAuthority.AUTHORITATIVE_PRESENT) return Boolean.TRUE;
671            if (authority == ColumnAuthority.AUTHORITATIVE_ABSENT) {
672                sawAuthoritativeAbsent = true;
673            } else {
674                sawUnavailable = true;
675            }
676        }
677        if (sawUnavailable) return null;
678        return sawAuthoritativeAbsent ? Boolean.FALSE : null;
679    }
680
681    private static Set<TTable> collectInputTables(TTable table) {
682        Set<TTable> out = new HashSet<TTable>();
683        collectInputTables(table, out);
684        return out;
685    }
686
687    private static void collectInputTables(TTable table, Set<TTable> out) {
688        if (table == null || out == null) return;
689        if (table.getTableType() == ETableSource.join && table.getJoinExpr() != null) {
690            TJoinExpr join = table.getJoinExpr();
691            collectInputTables(join.getLeftTable(), out);
692            collectInputTables(join.getRightTable(), out);
693            return;
694        }
695        out.add(table);
696    }
697
698    private void collectNaturalJoinDiagnostics(final List<BindingDiagnostic> diagnostics) {
699        if (config == null || !config.isBindingStrictCatalogValidation()) return;
700        TStatementList statements = resolver != null ? resolver.getStatements() : null;
701        if (statements == null) return;
702        final Set<TJoinExpr> seen = Collections.newSetFromMap(new IdentityHashMap<TJoinExpr, Boolean>());
703        for (int i = 0; i < statements.size(); i++) {
704            TCustomSqlStatement stmt = statements.get(i);
705            if (stmt == null) continue;
706            try {
707                stmt.acceptChildren(new TParseTreeVisitor() {
708                    @Override
709                    public void preVisit(TJoinExpr node) {
710                        if (node == null || !seen.add(node) || !isNaturalJoin(node.getJointype())) return;
711                        String text = node.toString();
712                        diagnostics.add(BindingDiagnosticBuilder
713                            .forCode(BindingDiagnosticCode.UNSUPPORTED_BINDING_SCOPE)
714                            .severity(severityFor(BindingDiagnosticCode.UNSUPPORTED_BINDING_SCOPE))
715                            .message("NATURAL JOIN binding semantics are not yet modeled.")
716                            .objectNameText(text != null && !text.isEmpty() ? text : "NATURAL JOIN")
717                            .site(joinSite("NATURAL JOIN"))
718                            .statement(null)
719                            .build());
720                    }
721                });
722            } catch (Throwable ignore) {
723                // Defensive: join diagnostics must never fail the post-pass.
724            }
725        }
726    }
727
728    private static boolean isNaturalJoin(EJoinType type) {
729        return type == EJoinType.natural
730            || type == EJoinType.natural_inner
731            || type == EJoinType.natural_left
732            || type == EJoinType.natural_right
733            || type == EJoinType.natural_full
734            || type == EJoinType.natural_leftouter
735            || type == EJoinType.natural_rightouter
736            || type == EJoinType.natural_fullouter;
737    }
738
739    private static String usingColumnName(TObjectName col) {
740        if (col == null) return null;
741        String name = col.getColumnNameOnly();
742        if (name == null || name.isEmpty()) return null;
743        // S14: keep the column name in its original form (with quotes if any).
744        // {@link BindingMetadataAuthority#lookup} passes the name to
745        // {@code namespace.hasColumn(...)} which routes through {@code
746        // INameMatcher} — VendorNameMatcher honors per-dialect quoted vs
747        // unquoted compare rules. Stripping quotes here would lose that
748        // distinction (e.g. Oracle USING("Id") would have falsely matched a
749        // catalog column folded to ID).
750        return name;
751    }
752
753    private static BindingReferenceSite joinSite(String text) {
754        return BindingReferenceSite.builder()
755            .clause(BindingClause.OTHER)
756            .referenceText(text == null || text.isEmpty() ? "JOIN" : text)
757            .build();
758    }
759
760    // ===== NOT_FOUND =====
761
762    private BindingDiagnostic buildNotFoundDiagnostic(TObjectName ref,
763                                                     ResolutionContext ctx,
764                                                     ScopeBuildResult sbr) {
765        String columnName = ref.getColumnNameOnly();
766        if (columnName == null || columnName.isEmpty()) {
767            return null;
768        }
769
770        String qualifier = qualifierOf(ref);
771        IScope scope = sbr.getScopeForColumn(ref);
772
773        // Star reference (`t.*` or bare `*`): wildcard expansion is not a column
774        // lookup. We MUST NOT emit UNKNOWN_COLUMN regardless of namespace state.
775        // Plan §7.3 S7: user-authored `unknown_alias.*` emits
776        // INVALID_STAR_QUALIFIER; everything else is silent.
777        if (isStarReference(ref, columnName)) {
778            if (qualifier == null || qualifier.isEmpty()) {
779                return null;
780            }
781            INamespace starNs = (scope != null) ? scope.resolveTable(qualifier) : null;
782            if (starNs == null) {
783                return BindingDiagnosticBuilder
784                    .forCode(BindingDiagnosticCode.INVALID_STAR_QUALIFIER)
785                    .severity(severityFor(BindingDiagnosticCode.INVALID_STAR_QUALIFIER))
786                    .message("Star qualifier " + qualifier
787                        + " does not match any table or alias in scope.")
788                    .objectNameText(ref.toString())
789                    .site(siteFor(ref))
790                    .statement(statementFor(ref, sbr))
791                    .build();
792            }
793            return null;
794        }
795
796        if (qualifier != null && !qualifier.isEmpty()) {
797            INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
798            if (ns == null) {
799                if (config != null && config.isBindingStrictCatalogValidation()) {
800                    return BindingDiagnosticBuilder
801                        .forCode(BindingDiagnosticCode.UNKNOWN_ALIAS)
802                        .severity(severityFor(BindingDiagnosticCode.UNKNOWN_ALIAS))
803                        .message("Qualifier " + qualifier
804                            + " does not match any table or alias in scope.")
805                        .objectNameText(ref.toString())
806                        .site(siteFor(ref))
807                        .statement(statementFor(ref, sbr))
808                        .build();
809                }
810                return null;
811            }
812            MetadataState state = ns.getMetadataState();
813            if (state == MetadataState.NOT_FOUND_IN_CATALOG) {
814                if (config != null && config.isBindingStrictCatalogValidation()) {
815                    return BindingDiagnosticBuilder
816                        .forCode(BindingDiagnosticCode.UNKNOWN_TABLE)
817                        .severity(severityFor(BindingDiagnosticCode.UNKNOWN_TABLE))
818                        .message("Table " + qualifier + " was not found in the catalog.")
819                        .objectNameText(ref.toString())
820                        .site(siteFor(ref))
821                        .statement(statementFor(ref, sbr))
822                        .build();
823                }
824                return null;
825            }
826            if (state == MetadataState.METADATA_UNAVAILABLE) {
827                if (config != null && config.isBindingStrictCatalogValidation()) {
828                    return BindingDiagnosticBuilder
829                        .forCode(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE)
830                        .severity(severityFor(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE))
831                        .message("Catalog metadata is unavailable for table " + qualifier + ".")
832                        .objectNameText(ref.toString())
833                        .site(siteFor(ref))
834                        .statement(statementFor(ref, sbr))
835                        .build();
836                }
837                return null;
838            }
839            ColumnAuthority authority = BindingMetadataAuthority.lookup(ns, columnName);
840            if (authority != ColumnAuthority.AUTHORITATIVE_ABSENT) {
841                return null;
842            }
843
844            String tableLabel = describeTable(ns);
845            return BindingDiagnosticBuilder
846                .forCode(BindingDiagnosticCode.UNKNOWN_COLUMN)
847                .severity(severityFor(BindingDiagnosticCode.UNKNOWN_COLUMN))
848                .message("Column " + ref.toString()
849                    + " was not found in catalog table " + tableLabel + ".")
850                .objectNameText(ref.toString())
851                .site(siteFor(ref))
852                .statement(statementFor(ref, sbr))
853                .build();
854        }
855
856        // Unqualified miss. Plan §7.3 S5: UNBOUND_COLUMN_REFERENCE fires when
857        // status NOT_FOUND AND no qualifier AND no in-scope table. Otherwise
858        // wait for S6+ to add ambiguous/contextual handling.
859        if (!hasInScopeTable(scope)) {
860            return BindingDiagnosticBuilder
861                .forCode(BindingDiagnosticCode.UNBOUND_COLUMN_REFERENCE)
862                .severity(severityFor(BindingDiagnosticCode.UNBOUND_COLUMN_REFERENCE))
863                .message("Column " + columnName
864                    + " could not be bound: no tables are in scope.")
865                .objectNameText(ref.toString())
866                .site(siteFor(ref))
867                .statement(statementFor(ref, sbr))
868                .build();
869        }
870        return null;
871    }
872
873    private BindingDiagnostic buildSetOperationOutputDiagnostic(TObjectName ref,
874                                                                ScopeBuildResult sbr) {
875        if (ref == null || sbr == null) return null;
876
877        String columnName = ref.getColumnNameOnly();
878        if (columnName == null || columnName.isEmpty()
879                || isStarReference(ref, columnName)) {
880            return null;
881        }
882
883        String qualifier = qualifierOf(ref);
884        if (qualifier == null || qualifier.isEmpty()) return null;
885
886        IScope scope = sbr.getScopeForColumn(ref);
887        INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
888        if (!(ns instanceof UnionNamespace)) return null;
889
890        UnionNamespace union = (UnionNamespace) ns;
891        ColumnLevel level = union.hasAuthoritativeOutputColumn(columnName);
892        if (level != ColumnLevel.NOT_EXISTS) return null;
893
894        String displayName = union.getDisplayName();
895        if (displayName == null || displayName.isEmpty()) {
896            displayName = qualifier;
897        }
898        return BindingDiagnosticBuilder
899            .forCode(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING)
900            .severity(severityFor(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING))
901            .message("Column " + ref.toString()
902                + " is not exposed by set-operation derived table "
903                + displayName + ".")
904            .objectNameText(ref.toString())
905            .site(siteFor(ref))
906            .statement(statementFor(ref, sbr))
907            .build();
908    }
909
910    private BindingDiagnostic buildCteOutputDiagnostic(TObjectName ref,
911                                                       ScopeBuildResult sbr) {
912        if (ref == null || sbr == null) return null;
913
914        String columnName = ref.getColumnNameOnly();
915        if (columnName == null || columnName.isEmpty()
916                || isStarReference(ref, columnName)) {
917            return null;
918        }
919
920        String qualifier = qualifierOf(ref);
921        if (qualifier == null || qualifier.isEmpty()) return null;
922
923        IScope scope = sbr.getScopeForColumn(ref);
924        INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
925        if (!(ns instanceof CTENamespace)) return null;
926
927        CTENamespace cte = (CTENamespace) ns;
928        ColumnLevel level = cte.hasAuthoritativeOutputColumn(columnName);
929        if (level != ColumnLevel.NOT_EXISTS) return null;
930
931        String cteName = cte.getDisplayName();
932        if (cteName == null || cteName.isEmpty()) {
933            cteName = qualifier;
934        }
935        return BindingDiagnosticBuilder
936            .forCode(BindingDiagnosticCode.CTE_OUTPUT_COLUMN_MISSING)
937            .severity(severityFor(BindingDiagnosticCode.CTE_OUTPUT_COLUMN_MISSING))
938            .message("Column " + ref.toString()
939                + " is not exposed by CTE " + cteName + ".")
940            .objectNameText(ref.toString())
941            .site(siteFor(ref))
942            .statement(statementFor(ref, sbr))
943            .build();
944    }
945
946    private BindingDiagnostic buildSubqueryOutputDiagnostic(TObjectName ref,
947                                                            ScopeBuildResult sbr) {
948        if (ref == null || sbr == null) return null;
949
950        String columnName = ref.getColumnNameOnly();
951        if (columnName == null || columnName.isEmpty()
952                || isStarReference(ref, columnName)) {
953            return null;
954        }
955
956        String qualifier = qualifierOf(ref);
957        if (qualifier == null || qualifier.isEmpty()) return null;
958
959        IScope scope = sbr.getScopeForColumn(ref);
960        INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
961        if (!(ns instanceof SubqueryNamespace)) return null;
962
963        SubqueryNamespace subquery = (SubqueryNamespace) ns;
964        ColumnLevel level = subquery.hasAuthoritativeOutputColumn(columnName);
965        if (level != ColumnLevel.NOT_EXISTS) return null;
966
967        String displayName = subquery.getDisplayName();
968        if (displayName == null || displayName.isEmpty()) {
969            displayName = qualifier;
970        }
971        return BindingDiagnosticBuilder
972            .forCode(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING)
973            .severity(severityFor(BindingDiagnosticCode.SUBQUERY_OUTPUT_COLUMN_MISSING))
974            .message("Column " + ref.toString()
975                + " is not exposed by derived table " + displayName + ".")
976            .objectNameText(ref.toString())
977            .site(siteFor(ref))
978            .statement(statementFor(ref, sbr))
979            .build();
980    }
981
982    /**
983     * For strict mode: checks a qualified reference's namespace state even when
984     * the resolver produced an exact-match result (inferred at low confidence
985     * against a NOT_FOUND_IN_CATALOG or METADATA_UNAVAILABLE table).
986     * Returns a diagnostic when the namespace warrants one, null otherwise.
987     */
988    private BindingDiagnostic buildStrictModeNamespaceDiagnostic(TObjectName ref,
989                                                                  ScopeBuildResult sbr) {
990        String qualifier = qualifierOf(ref);
991        if (qualifier == null || qualifier.isEmpty()) return null;
992        IScope scope = sbr.getScopeForColumn(ref);
993        INamespace ns = (scope != null) ? scope.resolveTable(qualifier) : null;
994        if (ns == null) return null;
995        MetadataState state = ns.getMetadataState();
996        if (state == MetadataState.NOT_FOUND_IN_CATALOG) {
997            return BindingDiagnosticBuilder
998                .forCode(BindingDiagnosticCode.UNKNOWN_TABLE)
999                .severity(severityFor(BindingDiagnosticCode.UNKNOWN_TABLE))
1000                .message("Table " + qualifier + " was not found in the catalog.")
1001                .objectNameText(ref.toString())
1002                .site(siteFor(ref))
1003                .statement(statementFor(ref, sbr))
1004                .build();
1005        }
1006        if (state == MetadataState.METADATA_UNAVAILABLE) {
1007            return BindingDiagnosticBuilder
1008                .forCode(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE)
1009                .severity(severityFor(BindingDiagnosticCode.CATALOG_METADATA_UNAVAILABLE))
1010                .message("Catalog metadata is unavailable for table " + qualifier + ".")
1011                .objectNameText(ref.toString())
1012                .site(siteFor(ref))
1013                .statement(statementFor(ref, sbr))
1014                .build();
1015        }
1016        return null;
1017    }
1018
1019    /**
1020     * Slice S16c (plan §5.6.2): verify a resolver2 {@link
1021     * gudusoft.gsqlparser.resolver2.ResolutionStatus#EXACT_MATCH} against
1022     * authoritative catalog metadata.
1023     *
1024     * <p>Resolver2's exact match proves only that SQL scope binding selected
1025     * one source; the column may still be authoritatively absent from that
1026     * source's catalog metadata. The post-pass therefore reads the resolver2-
1027     * selected {@link INamespace} out of the recorded {@link ResolutionResult}
1028     * and asks {@link BindingMetadataAuthority#lookup(INamespace, String)}
1029     * only — it never re-binds names, searches SQL scope, or mutates AST
1030     * state.</p>
1031     *
1032     * <ul>
1033     *   <li>{@link ColumnAuthority#AUTHORITATIVE_PRESENT} — keep the resolver2
1034     *       binding; emit no diagnostic. Successful references picked up
1035     *       upstream still flow.</li>
1036     *   <li>{@link ColumnAuthority#AUTHORITATIVE_ABSENT} — emit
1037     *       {@link BindingDiagnosticCode#UNKNOWN_COLUMN} against the
1038     *       resolver2-selected source. Resolver2's binding state is NOT
1039     *       mutated.</li>
1040     *   <li>{@link ColumnAuthority#METADATA_UNAVAILABLE} — return null. Strict
1041     *       mode's {@link #buildStrictModeNamespaceDiagnostic} may then emit
1042     *       {@link BindingDiagnosticCode#CATALOG_METADATA_UNAVAILABLE} based on
1043     *       namespace state; non-strict mode stays silent.</li>
1044     * </ul>
1045     *
1046     * <p>Star references (bare {@code *} or {@code t.*}) are wildcard
1047     * expansion sites, not column lookups, and are skipped here even if the
1048     * resolver tagged them as {@code EXACT_MATCH}.</p>
1049     *
1050     * <p>The column-name compare flows through
1051     * {@link BindingMetadataAuthority#lookup} → {@link INamespace#hasColumn}
1052     * → vendor {@link gudusoft.gsqlparser.resolver2.matcher.INameMatcher}
1053     * (typically {@code VendorNameMatcher} when the parser auto-wires the
1054     * resolver), so per-dialect quoted-vs-unquoted rules are honored without
1055     * any bespoke string compare here. Quotes on the column token are
1056     * preserved (we never call {@code stripQuotes}).</p>
1057     */
1058    private BindingDiagnostic verifyExactMatchAgainstCatalog(TObjectName ref,
1059                                                             ResolutionResult result,
1060                                                             ScopeBuildResult sbr) {
1061        if (ref == null || result == null) return null;
1062
1063        String columnName = ref.getColumnNameOnly();
1064        if (columnName == null || columnName.isEmpty()) return null;
1065
1066        // Star references are not column lookups — skip catalog verification.
1067        if (isStarReference(ref, columnName)) return null;
1068
1069        ColumnSource cs = result.getColumnSource();
1070        if (cs == null) return null;
1071        INamespace ns = cs.getSourceNamespace();
1072        if (ns == null) return null;
1073
1074        ColumnAuthority verdict = BindingMetadataAuthority.lookup(ns, columnName);
1075        if (verdict != ColumnAuthority.AUTHORITATIVE_ABSENT) {
1076            // PRESENT → resolver2 binding catalog-verified; no diagnostic.
1077            // METADATA_UNAVAILABLE → defer to strict-mode namespace handler.
1078            return null;
1079        }
1080
1081        String tableLabel = describeTable(ns);
1082        return BindingDiagnosticBuilder
1083            .forCode(BindingDiagnosticCode.UNKNOWN_COLUMN)
1084            .severity(severityFor(BindingDiagnosticCode.UNKNOWN_COLUMN))
1085            .message("Column " + ref.toString()
1086                + " was not found in catalog table " + tableLabel + ".")
1087            .objectNameText(ref.toString())
1088            .site(siteFor(ref))
1089            .statement(statementFor(ref, sbr))
1090            .build();
1091    }
1092
1093    /**
1094     * S13 — DML target column entries are column DECLARATIONS, not column
1095     * references. They must never trigger {@code UNKNOWN_COLUMN},
1096     * {@code UNBOUND_COLUMN_REFERENCE}, or any other binding diagnostic
1097     * regardless of whether the column exists in the catalog (plan §7.3 S13:
1098     * "target columns produce zero diagnostics").
1099     *
1100     * <p>Detection is via {@link ESqlClause} location stamped by the
1101     * statement's {@code doParseStatement} when populating the target column
1102     * list:</p>
1103     *
1104     * <ul>
1105     *   <li>{@link ESqlClause#insertColumn} — {@code INSERT INTO t (a,b,c)} target
1106     *       columns.</li>
1107     *   <li>{@link ESqlClause#mergeInsert} — {@code MERGE … WHEN NOT MATCHED THEN
1108     *       INSERT (a,b,c)} target columns.</li>
1109     *   <li>{@link ESqlClause#set} — {@code UPDATE … SET col = …} LHS targets
1110     *       (and the equivalent {@code MERGE … WHEN MATCHED THEN UPDATE SET}
1111     *       LHS in dialects where it inherits the {@code set} location).
1112     *       The legacy linker pre-resolves these against the target table so
1113     *       they are usually silent already, but tagging here records
1114     *       {@link BindingSkipReason#TARGET_COLUMN} explicitly so consumers
1115     *       reading {@code ResolutionContext} directly see intent rather
1116     *       than inferring it from absence.</li>
1117     * </ul>
1118     *
1119     * <p>RHS expressions ({@link ESqlClause#setValue}) are NOT skipped — they
1120     * are bona-fide value references and continue to emit when missing.</p>
1121     */
1122    private static boolean isDmlTargetColumn(TObjectName ref) {
1123        if (ref == null) return false;
1124        ESqlClause loc = ref.getLocation();
1125        return loc == ESqlClause.insertColumn
1126            || loc == ESqlClause.mergeInsert
1127            || loc == ESqlClause.set;
1128    }
1129
1130    private static boolean isStarReference(TObjectName ref, String columnName) {
1131        if ("*".equals(columnName)) {
1132            return true;
1133        }
1134        if (ref == null) return false;
1135        String text = ref.toString();
1136        return text != null && text.endsWith(".*");
1137    }
1138
1139    private static String qualifierOf(TObjectName ref) {
1140        if (ref == null) return null;
1141        String tbl = ref.getTableString();
1142        if (tbl != null && !tbl.isEmpty()) {
1143            // Slice S15: preserve quotes so downstream {@link
1144            // IScope#resolveTable(String)} (now matcher-routed in
1145            // ListBasedScope) can apply per-dialect quoted-vs-unquoted alias
1146            // rules (e.g. Oracle quoted alias is case-sensitive, unquoted
1147            // folds to upper). Stripping quotes here drops that distinction.
1148            return tbl;
1149        }
1150        return null;
1151    }
1152
1153    private static boolean hasInScopeTable(IScope scope) {
1154        if (scope == null) return false;
1155        try {
1156            List<INamespace> visible = scope.getVisibleNamespaces();
1157            return visible != null && !visible.isEmpty();
1158        } catch (Throwable ignore) {
1159            return false;
1160        }
1161    }
1162
1163    private static String describeTable(INamespace ns) {
1164        if (ns == null) return "<unknown>";
1165        if (ns.getSourceTable() != null && ns.getSourceTable().getName() != null) {
1166            return ns.getSourceTable().getName().toString();
1167        }
1168        if (ns.getFinalTable() != null && ns.getFinalTable().getName() != null) {
1169            return ns.getFinalTable().getName().toString();
1170        }
1171        String display = ns.getDisplayName();
1172        return (display == null || display.isEmpty()) ? "<unknown>" : display;
1173    }
1174
1175    // ===== shared =====
1176
1177    private BindingDiagnosticSeverity severityFor(BindingDiagnosticCode code) {
1178        if (config == null) {
1179            return code.defaultSeverity();
1180        }
1181        BindingDiagnosticSeverity sev = config.getBindingSeverityFor(code);
1182        return sev != null ? sev : code.defaultSeverity();
1183    }
1184
1185    private BindingReferenceSite siteFor(TObjectName ref) {
1186        String text = ref.toString();
1187        if (text == null || text.isEmpty()) {
1188            text = "<unknown>";
1189        }
1190        BindingClause clause = mapper.map(ref);
1191        return BindingReferenceSite.builder()
1192            .clause(clause)
1193            .referenceText(text)
1194            .build();
1195    }
1196
1197    private static TCustomSqlStatement statementFor(TObjectName ref, ScopeBuildResult sbr) {
1198        if (sbr == null || ref == null) return null;
1199        IScope scope = sbr.getScopeForColumn(ref);
1200        while (scope != null) {
1201            TParseTreeNode node = scope.getNode();
1202            if (node instanceof TCustomSqlStatement) {
1203                return (TCustomSqlStatement) node;
1204            }
1205            if (node instanceof TSelectSqlStatement) {
1206                return (TCustomSqlStatement) node;
1207            }
1208            IScope parent = scope.getParent();
1209            if (parent == scope) break;
1210            scope = parent;
1211        }
1212        return null;
1213    }
1214
1215    private BindingReference buildReference(TObjectName ref,
1216                                                   ScopeBuildResult sbr,
1217                                                   boolean bound) {
1218        if (ref == null) return null;
1219        String text = ref.toString();
1220        if (text == null || text.isEmpty()) return null;
1221        BindingReferenceKind kind = ref.toString().endsWith("*")
1222            ? BindingReferenceKind.STAR
1223            : BindingReferenceKind.COLUMN;
1224        return BindingReference.builder()
1225            .kind(kind)
1226            .objectNameText(text)
1227            .site(siteFor(ref))
1228            .bound(bound)
1229            .build();
1230    }
1231}