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}