001package gudusoft.gsqlparser;
002
003import java.util.ArrayList;
004import java.util.Collections;
005import java.util.List;
006
007/**
008 * Proof harness for the dynamic-SQL per-edge publication proof
009 * (design: {@code docs/designs/sp/dynamic-sql-fragment-provenance-design.md},
010 * Layer 2 / phase "R4 step 2").
011 *
012 * <p>This class exists in {@code gudusoft.gsqlparser} — NOT in dlineage —
013 * because the checks need the real pipeline's internals (lexer accumulation
014 * state, parser recovery events), which are package-local here. It is
015 * hand-written; it never modifies generated lexer/parser files.
016 *
017 * <p><b>Phase status: built, unwired.</b> No product path calls it yet; it is
018 * exercised only by its isolation tests. Everything it reports errs on the
019 * side of refusal: a {@code false}/failed verdict never causes wrong output,
020 * only (later, when wired) a smaller published-edge set.
021 *
022 * <p>Provided checks (design Layer 2):
023 * <ol>
024 *   <li><b>Boundary lex check</b> — {@link #checkLexBoundary}: the literal
025 *       prefix before the first hole must end in a clean lexer state (no
026 *       unterminated string/comment/bracketed identifier — observed as
027 *       non-empty {@code literalbuf} accumulation and swallowed characters),
028 *       with balanced paren depth, a fusion-safe boundary character, and (for
029 *       MSSQL) no line-comment-ambiguous {@code --} on the boundary line
030 *       (TLexerMssql classifies {@code --} by inspecting the whole current
031 *       line, so the hole's same-line content could flip it).</li>
032 *   <li><b>Proof-mode parse</b> — {@link #proofParse}: parse with the real
033 *       pipeline and refuse when the parse needed ANY recovery
034 *       ({@link TCustomParser#getProofRecoveryEvents}) or produced syntax
035 *       errors. Silent keyword rollback counts as recovery: a parse that
036 *       needed it is not evidence.</li>
037 *   <li><b>MSSQL rewrite-horizon audit</b> — {@link MssqlRewriteAudit}: the
038 *       per-rule forward-window facts from the review-round-2 audit, replacing
039 *       the unsound single global {@code W}.</li>
040 * </ol>
041 *
042 * <p>Deliberately NOT here yet (next increments): the differential
043 * prefix+EOF probe and the committed-node (parser commitment) check.
044 */
045public final class DynamicSqlProofHarness {
046
047    private DynamicSqlProofHarness() {
048    }
049
050    /* ------------------------------------------------------ boundary check */
051
052    /** Verdict of {@link #checkLexBoundary}; {@code clean == false} means the proof must refuse. */
053    public static final class BoundaryVerdict {
054        public final boolean clean;
055        /** Human-readable refusal cause; {@code null} when clean. */
056        public final String reason;
057
058        BoundaryVerdict(boolean clean, String reason) {
059            this.clean = clean;
060            this.reason = reason;
061        }
062
063        static BoundaryVerdict ok() {
064            return new BoundaryVerdict(true, null);
065        }
066
067        static BoundaryVerdict refuse(String reason) {
068            return new BoundaryVerdict(false, reason);
069        }
070
071        @Override
072        public String toString() {
073            return clean ? "clean" : "refused: " + reason;
074        }
075    }
076
077    /**
078     * Check that {@code prefix} — the literal text before the first hole — is a
079     * safe lexical boundary: every character lexed into complete tokens, no
080     * open literal/comment/bracket state, balanced parens/brackets, a
081     * fusion-safe final character, and no line-comment ambiguity on the
082     * boundary line.
083     */
084    public static BoundaryVerdict checkLexBoundary(String prefix, EDbVendor vendor) {
085        if (prefix == null || prefix.isEmpty()) {
086            return BoundaryVerdict.refuse("empty prefix");
087        }
088        TGSqlParser parser = new TGSqlParser(vendor);
089        parser.sqltext = prefix;
090        try {
091            parser.tokenizeSqltext();
092        } catch (Throwable t) {
093            return BoundaryVerdict.refuse("tokenization failed: " + t);
094        }
095
096        // 1. Coverage: the emitted tokens must reproduce the prefix exactly.
097        //    This is the load-bearing unterminatedness detector: an open
098        //    string / block comment / bracketed identifier is swallowed into
099        //    the lexer's literal buffer instead of being emitted, so the token
100        //    stream comes up short (verified empirically for MSSQL: 'abc,
101        //    /* xx, [ab; review round 1 additionally verified nested comments,
102        //    N'..., smart quotes and double quotes). Lexer end-state fields are
103        //    deliberately NOT used: literallen is residual scratch that stays
104        //    non-zero after a COMPLETED literal, and yysstate is vacuously 0
105        //    because EOF calls yyclear() (TCustomLexer:695).
106        StringBuilder covered = new StringBuilder(prefix.length());
107        for (int i = 0; i < parser.sourcetokenlist.size(); i++) {
108            covered.append(parser.sourcetokenlist.get(i).toString());
109        }
110        if (!covered.toString().equals(prefix)) {
111            return BoundaryVerdict.refuse("token stream does not cover the prefix exactly"
112                    + " (unterminated literal/comment/identifier or swallowed text)");
113        }
114
115        // 2. Delimiter balance: an unclosed ( or [ lets a hole instantiation
116        //    close into prefix structure. Brackets closed within a single
117        //    identifier token never appear here — only real punctuation tokens.
118        int parenDepth = 0;
119        for (int i = 0; i < parser.sourcetokenlist.size(); i++) {
120            String t = parser.sourcetokenlist.get(i).toString();
121            if ("(".equals(t)) {
122                parenDepth++;
123            } else if (")".equals(t)) {
124                parenDepth--;
125            }
126            if (parenDepth < 0) {
127                return BoundaryVerdict.refuse("unbalanced ')' in prefix");
128            }
129        }
130        if (parenDepth != 0) {
131            return BoundaryVerdict.refuse("prefix has " + parenDepth + " unclosed '('");
132        }
133
134        // 4. Fusion safety: the hole starts immediately after the prefix, so
135        //    the final EMITTED TOKEN must be one the vendor lexer itself
136        //    classifies as whitespace/newline, or a comma. Judging by the
137        //    lexer's classification, not Character.isWhitespace, closes the
138        //    Unicode-whitespace witness from review round 1: ' ' is Java
139        //    whitespace but NOT MSSQL lexer whitespace, so 't ' + hole
140        //    would fuse into a single identifier.
141        // ('(' is intentionally absent: a prefix ending in '(' always fails the
142        // balance check above, so it can never be an accepted boundary.)
143        TSourceToken lastToken = parser.sourcetokenlist.get(parser.sourcetokenlist.size() - 1);
144        boolean lexerBoundary = lastToken.tokentype == ETokenType.ttwhitespace
145                || lastToken.tokentype == ETokenType.ttreturn
146                || ",".equals(lastToken.toString());
147        if (!lexerBoundary) {
148            return BoundaryVerdict.refuse("final token '" + lastToken + "' ("
149                    + lastToken.tokentype + ") can fuse with hole text");
150        }
151
152        // 5. Lexer-mode dependence (design Layer 2, check 7): a double quote
153        //    lexes as an identifier or a string depending on the session's
154        //    QUOTED_IDENTIFIER setting, which is runtime state we cannot see.
155        //    Any double quote in the prefix makes its tokenization
156        //    mode-dependent — refuse conservatively.
157        if (vendor == EDbVendor.dbvmssql && prefix.indexOf('"') >= 0) {
158            return BoundaryVerdict.refuse("double quote in prefix is QUOTED_IDENTIFIER-dependent");
159        }
160
161        // 6. MSSQL line-comment ambiguity: TLexerMssql decides whether -- is a
162        //    comment or arithmetic by inspecting the WHOLE current line
163        //    (TLexerMssql:72), so hole content on the same line can flip the
164        //    classification of prefix text. Refuse any -- on the boundary line.
165        if (vendor == EDbVendor.dbvmssql) {
166            int nl = prefix.lastIndexOf('\n');
167            String boundaryLine = nl >= 0 ? prefix.substring(nl + 1) : prefix;
168            if (boundaryLine.contains("--")) {
169                return BoundaryVerdict.refuse("'--' on the boundary line is hole-content-dependent");
170            }
171        }
172        return BoundaryVerdict.ok();
173    }
174
175    /* ---------------------------------------------------- proof-mode parse */
176
177    /** Result of {@link #proofParse}; {@code ok == false} means the proof must refuse. */
178    public static final class ProofParseResult {
179        public final boolean ok;
180        public final int syntaxErrorCount;
181        /** Total recovery events: yyparse error handling + CREATE TABLE second-parse recovery. */
182        public final int recoveryEvents;
183        /**
184         * Typed subset of {@link #recoveryEvents}: deterministic
185         * keyword-to-identifier rollback attempts. Refused today (sound,
186         * conservative); typed separately so a future audited-rewrite
187         * refinement can relax it without changing this surface.
188         */
189        public final int keywordRollbackEvents;
190        /** Typed subset: CREATE TABLE / CREATE INDEX trimmed-body reparses (outside yyparse). */
191        public final int createTableReparseEvents;
192        /** Human-readable refusal cause; {@code null} when ok. */
193        public final String reason;
194
195        ProofParseResult(boolean ok, int syntaxErrorCount, int recoveryEvents,
196                int keywordRollbackEvents, int createTableReparseEvents, String reason) {
197            this.ok = ok;
198            this.syntaxErrorCount = syntaxErrorCount;
199            this.recoveryEvents = recoveryEvents;
200            this.keywordRollbackEvents = keywordRollbackEvents;
201            this.createTableReparseEvents = createTableReparseEvents;
202            this.reason = reason;
203        }
204
205        @Override
206        public String toString() {
207            return ok ? "ok" : "refused: " + reason
208                    + " (errors=" + syntaxErrorCount + ", recovery=" + recoveryEvents
209                    + ", keywordRollback=" + keywordRollbackEvents
210                    + ", createTableReparse=" + createTableReparseEvents + ")";
211        }
212    }
213
214    /**
215     * Parse {@code sql} with the real vendor pipeline and report whether the
216     * parse constitutes proof-grade evidence: zero syntax errors AND zero
217     * recovery events. Recovery includes silently-successful keyword rollback
218     * — a statement that only parses because a keyword was retried as an
219     * identifier is not instantiation-independent evidence.
220     */
221    public static ProofParseResult proofParse(String sql, EDbVendor vendor) {
222        if (sql == null || sql.trim().isEmpty()) {
223            return new ProofParseResult(false, 0, 0, 0, 0, "empty sql");
224        }
225        TGSqlParser parser = new TGSqlParser(vendor);
226        parser.sqltext = sql;
227        int errors;
228        try {
229            errors = parser.parse();
230        } catch (Throwable t) {
231            return new ProofParseResult(false, -1, -1, -1, -1, "parse threw: " + t);
232        }
233        int recovery = 0;
234        int keywordRollback = 0;
235        if (parser.fparser != null) {
236            recovery += parser.fparser.getProofRecoveryEvents();
237            keywordRollback += parser.fparser.getProofKeywordRollbackEvents();
238        }
239        if (parser.fplsqlparser != null) {
240            recovery += parser.fplsqlparser.getProofRecoveryEvents();
241            keywordRollback += parser.fplsqlparser.getProofKeywordRollbackEvents();
242        }
243        int createTableReparse = parser.proofCreateTableReparseEvents;
244        recovery += createTableReparse;
245        if (errors != 0) {
246            return new ProofParseResult(false, errors, recovery, keywordRollback,
247                    createTableReparse, "syntax errors");
248        }
249        if (recovery != 0) {
250            return new ProofParseResult(false, errors, recovery, keywordRollback,
251                    createTableReparse, "parse needed recovery");
252        }
253        return new ProofParseResult(true, 0, 0, 0, 0, null);
254    }
255
256    /* ------------------------------------------- LR configuration probe */
257
258    /**
259     * Run the REAL MSSQL parser over {@code sql} and report the LR automaton's
260     * configuration at {@code cutoffOffset} — the state stack the literal
261     * prefix committed to, plus every reduction performed before that point.
262     *
263     * <p>This is the observation half of the commitment check (design R4). It
264     * answers "what has the grammar actually committed to here?", which is the
265     * question two earlier hand-written schemes got wrong: token adjacency
266     * (`hasSolidTokenBetween`) and AST binder-region containment. Neither could
267     * see a construct the grammar still allows but the sentinel text does not
268     * contain — MSSQL's optional {@code UPDATE ... FROM} being the witness.
269     *
270     * <p>The real parser is used rather than an offline table simulation on
271     * purpose: MSSQL rewrites tokens before the automaton runs
272     * ({@code TParserMssqlSql.yyparse}) and reads the token stream from inside
273     * semantic actions, so a simulation would diverge precisely on the
274     * constructs those hacks exist for.
275     *
276     * <p>Read-only: the parse proceeds exactly as it otherwise would up to the
277     * cutoff, then stops without entering error recovery (recovery unwinds the
278     * stack we are reading).
279     *
280     * @param sql          the materialized site text
281     * @param cutoffOffset stop before consuming any token at or beyond this
282     *                     character offset — normally the first hole's offset,
283     *                     already retreated by the applicable rewrite horizon
284     * @return the configuration, or null if the parser never needed a token at
285     *         or beyond the cutoff (e.g. the cutoff is past the statement)
286     */
287    public static TCustomParser.ProofLrConfiguration observeLrConfiguration(String sql,
288            long cutoffOffset) {
289        if (sql == null || sql.trim().isEmpty()) {
290            return null;
291        }
292        TGSqlParser tokenizer = new TGSqlParser(EDbVendor.dbvmssql);
293        tokenizer.sqltext = sql;
294        tokenizer.tokenizeSqltext();
295        if (tokenizer.sourcetokenlist == null || tokenizer.sourcetokenlist.size() == 0) {
296            return null;
297        }
298        TParserMssqlSql parser = new TParserMssqlSql(tokenizer.sourcetokenlist);
299        parser.nf = new gudusoft.gsqlparser.nodes.TNodeFactory(EDbVendor.dbvmssql);
300        parser.setProofLrCutoffOffset(cutoffOffset);
301        try {
302            parser.yyparse();
303        } catch (Throwable t) {
304            // A throw after the snapshot was taken still yields a usable
305            // configuration; before it, null is the honest answer.
306            return parser.getProofLrConfiguration();
307        }
308        return parser.getProofLrConfiguration();
309    }
310
311    /* ---------------------------------------------- MSSQL rewrite horizons */
312
313    /**
314     * Per-rule forward-window audit of the MSSQL pre-parse machinery (review
315     * round 2 replaced the unsound single global {@code W} with this table).
316     * A construct reachable by an EXCLUDED rule cannot be proven by token-window
317     * retreat at all; the others bound how many solid tokens the stable-region
318     * boundary must retreat from the first hole.
319     */
320    public static final class MssqlRewriteAudit {
321
322        private MssqlRewriteAudit() {
323        }
324
325        /** Parser keyword pre-pass (TParserMssqlSql yyparse rewrite): 1 forward solid token. */
326        public static final int KEYWORD_PREPASS_WINDOW = 1;
327        /** Ordinary raw-statement rewrites (MssqlSqlParser): 1 forward solid token. */
328        public static final int RAW_REWRITE_WINDOW = 1;
329        /** Statement splitter decisions: up to 2 forward solid tokens. */
330        public static final int SPLITTER_WINDOW = 2;
331        /**
332         * Conservative local constant for the audited ordinary-DML subset:
333         * max of the bounded windows above plus one. NOT a global MSSQL
334         * constant — constructs reachable by {@link #EXCLUDED_RULES} are out.
335         */
336        public static final int W_ORDINARY_DML = 3;
337
338        /** How a rule's influence on the token stream is bounded — or not. */
339        public enum Enforcement {
340            /** Bounded window; covered by the {@code W} retreat. */
341            WINDOWED,
342            /** Refused today by a harness check (see {@link RewriteRule#note}). */
343            REFUSED_BY_HARNESS,
344            /**
345             * Not yet enforceable here — lands with the commitment check
346             * (design R4 step 4). Until then, constructs reachable by this
347             * rule must not be treated as provable.
348             */
349            PENDING_COMMITMENT_CHECK
350        }
351
352        /** One audited pre-parse/lexer rule: its forward window, or why it is excluded. */
353        public static final class RewriteRule {
354            public final String name;
355            /** Forward solid-token window; -1 when unbounded/non-token-local. */
356            public final int window;
357            public final Enforcement enforcement;
358            public final String note;
359
360            RewriteRule(String name, int window, Enforcement enforcement, String note) {
361                this.name = name;
362                this.window = window;
363                this.enforcement = enforcement;
364                this.note = note;
365            }
366
367            public boolean excluded() {
368                return window < 0;
369            }
370        }
371
372        /** The full audited rule table (review round 2). */
373        public static final List<RewriteRule> RULES;
374
375        static {
376            List<RewriteRule> r = new ArrayList<RewriteRule>();
377            r.add(new RewriteRule("parser keyword pre-pass", KEYWORD_PREPASS_WINDOW,
378                    Enforcement.WINDOWED, "TParserMssqlSql yyparse rewrite"));
379            r.add(new RewriteRule("ordinary raw-statement rewrites", RAW_REWRITE_WINDOW,
380                    Enforcement.WINDOWED, "MssqlSqlParser raw pass"));
381            r.add(new RewriteRule("statement splitter", SPLITTER_WINDOW,
382                    Enforcement.WINDOWED, "raw statement splitting decisions"));
383            r.add(new RewriteRule("CREATE TABLE/INDEX second-parse recovery", -1,
384                    Enforcement.REFUSED_BY_HARNESS,
385                    "outside yyparse; counted via TGSqlParser.proofCreateTableReparseEvents"));
386            r.add(new RewriteRule("constraint rewriting", -1,
387                    Enforcement.PENDING_COMMITMENT_CHECK,
388                    "carries state across unbounded modifier tokens (MssqlSqlParser:408)"));
389            r.add(new RewriteRule("EXEC module-number end-of-line scan", -1,
390                    Enforcement.PENDING_COMMITMENT_CHECK, "MssqlSqlParser:749"));
391            r.add(new RewriteRule("lexer '--' whole-line classification", -1,
392                    Enforcement.REFUSED_BY_HARNESS,
393                    "TLexerMssql:72; refused by checkLexBoundary boundary-line rule"));
394            r.add(new RewriteRule("semantic-action out-of-band reads", -1,
395                    Enforcement.PENDING_COMMITMENT_CHECK,
396                    "read_to_semicolon / read_to_next_parentheses (TCustomParser:296)"));
397            RULES = Collections.unmodifiableList(r);
398        }
399    }
400}