diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0563aa78..db750411f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1456,6 +1456,9 @@ jobs: run: | sudo apt-get update sudo apt-get install -y z3 + - name: Install SQLFluff + run: | + pip install sqlfluff - uses: dtolnay/rust-toolchain@stable - uses: actions/setup-go@v5 with: @@ -1474,6 +1477,7 @@ jobs: run: | cargo build --release --manifest-path gems/fact-mine/Cargo.toml cargo build --release --manifest-path gems/decomplex/Cargo.toml + cargo build --release --manifest-path gems/sql-cov/Cargo.toml - name: Download Ruby coverage artifacts if: ${{ needs['ruby-gems-coverage'].result == 'success' || needs['ruby-unit'].result == 'success' || needs['ruby-integration'].result == 'success' || needs['transpile-tests'].result == 'success' || needs['examples-benchmarks-coverage-shard'].result == 'success' || needs['tools-fuzz-shard'].result == 'success' || needs['bc-lower-coverage-shard'].result == 'success' }} uses: actions/download-artifact@v4 @@ -1516,6 +1520,11 @@ jobs: --top=80 \ --decomplex-binary=./gems/decomplex/target/release/decomplex-rust \ "${coverage_args[@]}" + ./tools/generate_sql_cov_sarif.rb \ + --repo=. \ + --out-dir=tmp/generalized-gems-sarif \ + --setup=gems/lineage/sql/storage/init_schema.sql \ + --sql-cov-bin=./gems/sql-cov/target/release/sql-cov - name: Upload Decomplex SARIF uses: github/codeql-action/upload-sarif@v4 with: @@ -1541,6 +1550,11 @@ jobs: with: sarif_file: tmp/generalized-gems-sarif/nil-kill.sarif category: nil-kill + - name: Upload SQL-cov SARIF + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: tmp/generalized-gems-sarif/sql-cov.sarif + category: sql-cov - uses: actions/upload-artifact@v4 with: name: generalized-gems-sarif diff --git a/codecov.yml b/codecov.yml index 383ca10e4..7f6b8562b 100644 --- a/codecov.yml +++ b/codecov.yml @@ -42,7 +42,7 @@ comment: flags: ruby: paths: - - src/ + - compiler/ carryforward: false joined: false zig: diff --git a/compiler/spec/ast_coverage_burndown_spec.rb b/compiler/spec/ast_coverage_burndown_spec.rb index 7ac90535b..25dc6127a 100644 --- a/compiler/spec/ast_coverage_burndown_spec.rb +++ b/compiler/spec/ast_coverage_burndown_spec.rb @@ -14,7 +14,7 @@ require_relative "../ruby/ast/type" unless defined?(Type) require_relative "../ruby/annotator/helpers/function_signature" unless defined?(FunctionSignature::AnalysisFacts) -RSpec.describe "AST coverage burndown" do +RSpec.describe ClearParser do def token(type = :VAR_ID, value = "x", line: 1, column: 1) Lexer::Token.new(type, value, line, column) end diff --git a/gems/README.md b/gems/README.md index d4fff5cf3..70063324d 100644 --- a/gems/README.md +++ b/gems/README.md @@ -13,15 +13,14 @@ quality work scalable. ## Projects - `decomplex`: static complexity and duplicated-decision analysis. -- `boobytrap`: risk ranking from coverage, branch gaps, churn, and - structural evidence. +- `boobytrap`: risk ranking from coverage, branch gaps, churn, and structural evidence. - `slopcop`: sloppiness and guardrail reporting. - `nil-kill`: nil/type-pressure evidence. -- `auto-type`: automated type and nilability rewrites driven by analyzer - evidence. +- `auto-type`: automated type and nilability rewrites driven by analyzer evidence. - `espalier`: architecture and public-surface analysis. - `lineage`: source/test/quality lineage UI and ingestion tooling. - `zig-mutants`: mutation testing for Zig runtime/lib code. +- `sql-cov`: dynamic and schema-aware SQL branch coverage and logic hazard analysis. ## Contribution Rules diff --git a/gems/decomplex/docs/agents/iterator-invalidation-reliability.md b/gems/decomplex/docs/agents/iterator-invalidation-reliability.md new file mode 100644 index 000000000..5638cec2d --- /dev/null +++ b/gems/decomplex/docs/agents/iterator-invalidation-reliability.md @@ -0,0 +1,72 @@ +# Iterator Invalidation Detector: Product and Architecture Decision + +Status: **Do not implement the proposed all-language detector.** + +The detailed extraction and feasibility assessment lives in +`gems/fact-mine/docs/agents/iterator-invalidation-reliability.md`. This document +records the Decomplex-side decision and prevents a future agent from building a +plausible-looking detector on insufficient facts. + +## Decision + +Do not add an `IteratorInvalidationDetector` based on loop spans, textual +collection variables, mutation method names, or `(method, parameter-index)` +reachability. Do not put language mutation registries or language branches in a +Decomplex detector. + +Across the 15 supported languages, mutation during traversal can mean undefined +behavior, a fail-fast runtime exception, a compiler error, defined live traversal, +snapshot/copy traversal, a permitted operation, or merely surprising logic. +Calling all of these “iterator invalidation” would be inaccurate. Reliably +distinguishing them requires resolved types and symbols, heap-object aliasing, +call/return/callback flow, and collection/version-specific contracts. Current +FactMine facts provide none of those guarantees. + +This is also low-value relative to its cost: + +- Rust already rejects the ordinary safe-borrow case. +- Java, C#, C++, Swift, TypeScript, and Zig need compiler semantic services for + useful precision. +- C needs a points-to analysis plus annotated library/allocator contracts. +- Ruby, Python, JavaScript, Lua, and PHP retain unresolved dynamic behavior even + after substantial analysis. +- Go deliberately specifies several mutations during `range`; mutation is not + synonymous with invalidation. +- Existing compilers, runtimes, and focused linters already cover many of the + strongest cases. + +## Architectural boundary + +If future evidence justifies a narrow implementation, FactMine must emit a +language-neutral, already-adjudicated `IteratorInteraction` fact containing: + +- stable iteration and mutation IDs/spans; +- resolved iterable and mutation target value identity; +- resolved iteration and mutation contracts; +- an outcome such as `invalidated`, `runtime_fail_fast`, `traversal_changes`, + `safe`, or `unknown`; +- the contract source and confidence; and +- enough evidence to explain why the mutation targets the traversed object. + +Decomplex may consume only those facts. It must not: + +- parse source or normalized nodes; +- compare source-text receiver/argument names; +- infer aliases or callees; +- own standard-library mutation lists; +- inspect `Document.language`; or +- reinterpret `unknown` as hazardous. + +This follows the existing boundary: FactMine establishes semantic evidence; +Decomplex groups, thresholds, explains, and reports it. + +## Reconsideration gate + +Reconsider only after a separate, compiler-backed, standard-library-only pilot +for one language achieves at least 95% reviewed precision on real repositories +and finds useful issues not already reported by that ecosystem's compiler or +standard linters. C++/Clang, Java/javac or JDT, and C#/Roslyn are plausible pilot +choices. A Tree-sitter-only or YAML-registry prototype is not evidence that the +design works. + +Until that gate is met, the correct implementation is no implementation. diff --git a/gems/espalier/README.md b/gems/espalier/README.md index adedc78b6..bcc0f43a4 100644 --- a/gems/espalier/README.md +++ b/gems/espalier/README.md @@ -69,6 +69,20 @@ bundle exec gems/espalier/exe/espalier \ Espalier can output a compact architecture manifest for tools and LLMs, or a Markdown report for human architectural review. +### Versioned architecture graph + +```bash +bundle exec gems/espalier/exe/espalier \ + --format architecture \ + --output=tmp/espalier-architecture.json \ + src/ +``` + +This output contains stable owner/function/state IDs, first-class call and +state-access edges, confidence, source citations, and explainable function +pressure. It is the structured ingestion contract for Lineage; SARIF, DOT, and +human-readable manifests are not parsed to reconstruct the graph. + ### Architecture Report ```bash diff --git a/gems/espalier/exe/espalier b/gems/espalier/exe/espalier index 4eab2c8f0..f609e2aa1 100755 --- a/gems/espalier/exe/espalier +++ b/gems/espalier/exe/espalier @@ -24,7 +24,7 @@ end OptionParser.new do |opts| opts.banner = "Usage: espalier [options] " - opts.on("-f", "--format FORMAT", [:markdown, :yaml, :report, :dot, :sarif, :json], "Output format (markdown, yaml, report, dot, sarif, json). Default: markdown") do |f| + opts.on("-f", "--format FORMAT", [:markdown, :yaml, :report, :dot, :sarif, :json, :architecture], "Output format (markdown, yaml, report, dot, sarif, json, architecture). Default: markdown") do |f| options[:format] = f end @@ -136,7 +136,22 @@ if options[:risk] && File.exist?(options[:risk]) end # Extract Static Skeletons -evidence = Espalier::StaticEvidence.build(targets, root: Dir.pwd, vcs: options[:vcs]) +evidence = Espalier::StaticEvidence.build( + targets, + root: Dir.pwd, + vcs: options[:vcs], + include_annotations: options[:format] != :architecture +) + +if options[:format] == :architecture + output_contents = JSON.pretty_generate(Espalier::ArchitectureArtifact.build(evidence, root: Dir.pwd)) + if options[:output] + File.write(options[:output], output_contents) + else + puts output_contents + end + exit 0 +end # Group flat evidence into module structures expected by aggregator/analyzers modules = Espalier::StaticEvidence.project_modules(evidence) diff --git a/gems/espalier/lib/espalier.rb b/gems/espalier/lib/espalier.rb index b9e51b315..22bafd267 100644 --- a/gems/espalier/lib/espalier.rb +++ b/gems/espalier/lib/espalier.rb @@ -14,6 +14,7 @@ module Espalier require_relative "espalier/architecture_analyzer" require_relative "espalier/aggregator" require_relative "espalier/dependency_graph" +require_relative "espalier/architecture_artifact" require_relative "espalier/graphviz_formatter" require_relative "espalier/formatter" require_relative "espalier/reporter" diff --git a/gems/espalier/lib/espalier/aggregator.rb b/gems/espalier/lib/espalier/aggregator.rb index bed5c055f..da5d6e7b3 100644 --- a/gems/espalier/lib/espalier/aggregator.rb +++ b/gems/espalier/lib/espalier/aggregator.rb @@ -111,6 +111,9 @@ def aggregate(modules) big_o_result = analyzer.analyze_method(key, ast_nodes, local_types: local_types_for_signature(sig)) quality[:big_o] = big_o_result[:lower_bound_complexity] + quality[:big_o_space] = big_o_result[:space_complexity] if big_o_result[:space_complexity] + quality[:big_o_dynamic] = big_o_result[:is_dynamic] + quality[:complexity_trigger] = big_o_result[:trigger] if big_o_result[:trigger] quality[:big_o_warnings] = big_o_result[:warnings] unless big_o_result[:warnings].empty? quality[:big_o_unknowns] = big_o_result[:unknown_operations] unless big_o_result[:unknown_operations].empty? diff --git a/gems/espalier/lib/espalier/architecture_artifact.rb b/gems/espalier/lib/espalier/architecture_artifact.rb new file mode 100644 index 000000000..7fa7f0250 --- /dev/null +++ b/gems/espalier/lib/espalier/architecture_artifact.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require "digest" +require "open3" +require "time" + +module Espalier + # Projects FactMine's lossless, normalized records into the versioned graph + # contract consumed by Lineage. Human report formats are deliberately derived + # from a different compatibility projection and are never parsed here. + module ArchitectureArtifact + module_function + + SCHEMA_VERSION = 1 + + def build(evidence, root: evidence["root"], commit: nil) + owners = Array(evidence["owners"]) + methods = Array(evidence["methods"]) + fields = Array(evidence["fields"]) + calls = Array(evidence.dig("facts", "calls")) + accesses = Array(evidence.dig("facts", "state_accesses")) + + nodes = [] + owners.each { |owner| nodes << owner_node(owner, root) } + methods.each { |method| nodes << function_node(method, root) } + fields.each { |field| nodes << state_node(field, root) } + + nodes_by_id = nodes.to_h { |node| [node["id"], node] } + method_by_id = methods.to_h { |method| [method["id"], method] } + methods_by_owner_name = methods.group_by { |method| [method["owner"], method["name"]] } + owners_by_name = owner_name_index(owners) + fields_by_owner_name = fields.to_h do |field| + [[field["owner"], normalize_field(field["name"])], field] + end + + edges = [] + calls.each do |call| + target = call["target"] + target ||= resolve_call_target(call, methods_by_owner_name, owners_by_name, fields_by_owner_name) + resolved = !target.nil? + unless target + external_id = external_node_id(call) + nodes_by_id[external_id] ||= external_node(external_id, call) + target = external_id + end + kind = call["kind"] || "calls" + kind = "internal_call" if resolved && kind == "unresolved_call" + kind = "delegation" if resolved && kind == "external_call" + edges << relationship_edge(call, call["source"], target, kind, root) + end + + accesses.each do |access| + source, target = if access["kind"] == "reads" + [access["state_id"], access["function_id"]] + else + [access["function_id"], access["state_id"]] + end + edges << relationship_edge(access, source, target, access["kind"], root) + end + + nodes = nodes_by_id.values + edges = merge_edges(edges) + cyclic = cyclic_node_ids(edges) + cyclic.each do |node_id| + metadata = nodes_by_id.dig(node_id, "metadata") + metadata["cycle"] = true if metadata + end + pressure = pressure_rows(methods, edges, cyclic) + pressure.each do |row| + metadata = nodes_by_id.dig(row["node_id"], "metadata") + metadata["pressure"] = row if metadata + end + + { + "schema_version" => SCHEMA_VERSION, + "kind" => "espalier.architecture.v1", + "analyzer" => { "name" => "espalier", "version" => Espalier::VERSION }, + "generated_at" => Time.now.utc.iso8601, + "corpus" => { + "commit" => commit || current_commit(root), + "root" => root.to_s, + "complete" => true, + "language_capabilities" => evidence["language_capabilities"] || {} + }, + "nodes" => nodes.sort_by { |node| [node["kind"], node["path"].to_s, node["start_line"].to_i, node["id"]] }, + "edges" => edges.sort_by { |edge| [edge["source"], edge["target"], edge["kind"], edge["id"]] }, + "pressure" => pressure.sort_by { |row| [-row["score"], row["node_id"]] } + } + end + + def owner_node(owner, root) + base_node(owner, "owner", root).merge( + "metadata" => { + "owner_kind" => owner["kind"], + "confidence" => owner["confidence"] || "high" + } + ) + end + + def function_node(method, root) + base_node(method, "function", root).merge( + "owner_id" => method["owner_id"], + "metadata" => { + "visibility" => method["visibility"] || "public", + "signature" => method["signature"], + "local_complexity" => method["local_complexity"] || 0.0, + "complexity_signals" => method["complexity_signals"] || {}, + "confidence" => "high" + } + ) + end + + def state_node(field, root) + base_node(field, "state", root).merge( + "owner_id" => field["owner_id"], + "metadata" => { + "declared_type" => field["declared_type"], + "type_references" => field["type_references"] || [], + "confidence" => "high" + } + ) + end + + def base_node(record, kind, root) + span = Array(record["span"]) + { + "id" => record["id"], + "kind" => kind, + "name" => record["name"], + "owner" => record["owner"] || record["name"], + "language" => record["language"], + "path" => relative_path(record["path"], root), + "start_line" => (span[0] || record["line"] || 1).to_i, + "start_column" => (span[1] || 0).to_i, + "end_line" => (span[2] || record["line"] || 1).to_i, + "end_column" => (span[3] || 0).to_i + } + end + + def owner_name_index(owners) + grouped = owners.group_by { |owner| owner["name"] } + owners.each do |owner| + simple = owner["name"].to_s.split(/::|\./).last + grouped[simple] ||= [] + grouped[simple] << owner unless grouped[simple].include?(owner) + end + grouped + end + + def resolve_call_target(call, methods_by_owner_name, owners_by_name, fields_by_owner_name) + receiver = call["receiver"].to_s + owner = call["owner"].to_s + message = call["message"].to_s + if receiver.empty? || %w[self this].include?(receiver) + return unique_method_id(methods_by_owner_name[[owner, message]]) + end + + receiver_owner = Array(owners_by_name[receiver]).one? && owners_by_name[receiver].first["name"] + unless receiver_owner + field_name = normalize_field(receiver.sub(/\A(?:self|this)\./, "").split(".").first) + field = fields_by_owner_name[[owner, field_name]] + receiver_owner = type_owner(field, owners_by_name) if field + end + unique_method_id(methods_by_owner_name[[receiver_owner, message]]) if receiver_owner + end + + def unique_method_id(rows) + Array(rows).one? ? rows.first["id"] : nil + end + + def type_owner(field, owners_by_name) + candidates = Array(field["type_references"]).flat_map do |reference| + reference.is_a?(Hash) ? reference.values : reference + end + candidates.concat(type_tokens(field["declared_type"])) + candidates.each do |candidate| + rows = owners_by_name[candidate.to_s] + return rows.first["name"] if Array(rows).one? + end + nil + end + + def type_tokens(value) + case value + when Hash then value.values.flat_map { |child| type_tokens(child) } + when Array then value.flat_map { |child| type_tokens(child) } + else value.to_s.scan(/[A-Z][A-Za-z0-9_:$]*/) + end + end + + def normalize_field(name) + name.to_s.sub(/\A@/, "") + end + + def external_node_id(call) + label = call["receiver"].to_s + label = call["message"].to_s if label.empty? || %w[self this].include?(label) + "external:#{Digest::SHA256.hexdigest(label)[0, 16]}" + end + + def external_node(id, call) + { + "id" => id, + "kind" => "external", + "name" => begin + receiver = call["receiver"].to_s + receiver.empty? || %w[self this].include?(receiver) ? call["message"] : receiver + end, + "owner" => nil, + "language" => nil, + "path" => nil, + "start_line" => 0, + "start_column" => 0, + "end_line" => 0, + "end_column" => 0, + "metadata" => { + "confidence" => call["confidence"] || "partial", + "unresolved_reason" => call["unresolved_reason"] + } + } + end + + def relationship_edge(record, source, target, kind, root) + span = Array(record["span"]) + { + "id" => record["id"] || "edge:#{Digest::SHA256.hexdigest([source, target, kind, span].join("\0"))[0, 16]}", + "source" => source, + "target" => target, + "kind" => kind, + "conditional" => !!record["conditional"], + "confidence" => record["confidence"] || "high", + "weight" => 1, + "spans" => [{ + "path" => relative_path(record["path"], root), + "start_line" => (span[0] || record["line"] || 1).to_i, + "start_column" => (span[1] || 0).to_i, + "end_line" => (span[2] || record["line"] || 1).to_i, + "end_column" => (span[3] || 0).to_i + }], + "metadata" => { + "receiver" => record["receiver"], + "message" => record["message"], + "unresolved_reason" => record["unresolved_reason"] + }.compact + } + end + + def merge_edges(edges) + edges.group_by { |edge| [edge["source"], edge["target"], edge["kind"], edge["conditional"], edge.dig("metadata", "message")] }.map do |_key, rows| + first = rows.first.dup + first["weight"] = rows.sum { |row| row["weight"].to_i } + first["spans"] = rows.flat_map { |row| row["spans"] }.uniq + first + end + end + + def cyclic_node_ids(edges) + call_edges = edges.select { |edge| edge["kind"].include?("call") || edge["kind"] == "delegation" } + adjacency = call_edges.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |edge, out| + out[edge["source"]] << edge["target"] + end + call_edges.each_with_object([]) do |edge, cyclic| + source = edge["source"] + target = edge["target"] + if source == target || reachable?(adjacency, target, source) + cyclic << source + cyclic << target + end + end.uniq + end + + def reachable?(adjacency, start, goal) + pending = [start] + seen = {} + until pending.empty? + current = pending.pop + return true if current == goal + next if seen[current] + + seen[current] = true + pending.concat(adjacency[current]) + end + false + end + + def pressure_rows(methods, edges, cyclic) + incoming = edges.group_by { |edge| edge["target"] } + outgoing = edges.group_by { |edge| edge["source"] } + methods.map do |method| + ins = Array(incoming[method["id"]]) + outs = Array(outgoing[method["id"]]) + calls = outs.count { |edge| edge["kind"].include?("call") || edge["kind"] == "delegation" } + reads = ins.count { |edge| edge["kind"] == "reads" } + writes = outs.count { |edge| edge["kind"] == "writes" } + collaborators = outs.filter_map { |edge| edge["target"] unless edge["kind"] == "writes" }.uniq.size + in_cycle = cyclic.include?(method["id"]) + collaboration = [((ins.size + calls + collaborators + (in_cycle ? 2 : 0)) / 12.0), 1.0].min + state = [((reads + writes * 2) / 8.0), 1.0].min + implementation = [(method["local_complexity"].to_f / 20.0), 1.0].min + active_families = [collaboration, state, implementation].count(&:positive?) + score = ((collaboration * 0.35 + state * 0.35 + implementation * 0.30) * 100).round(1) + band = score >= 75 && active_families >= 2 ? "red" : score >= 55 ? "orange" : score >= 30 ? "amber" : "ordinary" + { + "node_id" => method["id"], + "score" => score, + "band" => band, + "components" => { + "collaboration" => collaboration.round(3), + "state" => state.round(3), + "implementation" => implementation.round(3), + "operational" => 0.0 + }, + "explanation" => { + "callers" => ins.count { |edge| edge["kind"].include?("call") }, + "callees" => calls, + "state_reads" => reads, + "state_writes" => writes, + "collaborators" => collaborators, + "local_complexity" => method["local_complexity"].to_f, + "cycle" => in_cycle + } + } + end + end + + def current_commit(root) + return "" if root.to_s.empty? + + output, status = Open3.capture2("git", "-C", root.to_s, "rev-parse", "HEAD") + status.success? ? output.strip : "" + rescue StandardError + "" + end + + def relative_path(path, root) + return path if path.to_s.empty? || root.to_s.empty? + + expanded = File.expand_path(path.to_s) + prefix = File.expand_path(root.to_s) + File::SEPARATOR + expanded.start_with?(prefix) ? expanded.delete_prefix(prefix) : path + end + end +end diff --git a/gems/espalier/lib/espalier/big_o_analyzer.rb b/gems/espalier/lib/espalier/big_o_analyzer.rb index 3b29c5dfc..0a3a6b57b 100644 --- a/gems/espalier/lib/espalier/big_o_analyzer.rb +++ b/gems/espalier/lib/espalier/big_o_analyzer.rb @@ -58,6 +58,9 @@ def analyze_method(method_name, ast_nodes, local_types: nil) previous_local_types = @local_types @local_types = local_types if local_types complexity = "O(1)" + space_complexity = "O(1)" + is_dynamic = false + trigger = nil unknown_operations = [] warnings = [] @@ -92,9 +95,17 @@ def analyze_method(method_name, ast_nodes, local_types: nil) # nesting tree, multiple observed loops in one method are a sequential # lower bound, not proof of nested O(N^k) behavior. complexity = max_complexity(complexity, "O(N)") + is_dynamic = true elsif node[:type] == :structural structural_complexity = node[:complexity].to_s complexity = max_complexity(complexity, structural_complexity) + if node[:space] + space_complexity = max_space_complexity(space_complexity, node[:space].to_s) + end + if node[:is_dynamic] + is_dynamic = true + trigger ||= node[:trigger] + end warnings << structural_warning(node) if structural_complexity != "O(1)" elsif node[:type] == :callback || node[:type] == :yield warnings << "Function pointer / callback executed at line #{node[:line]}. This could execute arbitrary O(N^x) code, meaning our calculation is strictly a LOWER BOUND." @@ -104,6 +115,9 @@ def analyze_method(method_name, ast_nodes, local_types: nil) { method: method_name, lower_bound_complexity: complexity, + space_complexity: space_complexity, + is_dynamic: is_dynamic, + trigger: trigger, unknown_operations: unknown_operations.uniq, warnings: warnings.uniq } @@ -316,5 +330,20 @@ def multiply_complexity(current, multiplier) new_log ? "O(N^#{new_pow} log N)" : "O(N^#{new_pow})" end end + + def max_space_complexity(current, added) + r1 = space_complexity_rank(current) + r2 = space_complexity_rank(added) + r1 > r2 ? current : added + end + + def space_complexity_rank(space) + case space.to_s + when "O(N)" then 10 + when "O(log N)" then 5 + when "O(1)" then 1 + else 1 + end + end end end diff --git a/gems/espalier/lib/espalier/static_evidence.rb b/gems/espalier/lib/espalier/static_evidence.rb index 2d47c5a15..4368bc322 100644 --- a/gems/espalier/lib/espalier/static_evidence.rb +++ b/gems/espalier/lib/espalier/static_evidence.rb @@ -33,8 +33,16 @@ def self.project_modules(evidence) # Group methods by owner methods_by_owner = Hash.new { |h, k| h[k] = [] } + methods_by_id = {} + accesses_by_function = Hash.new { |h, k| h[k] = [] } + Array(evidence.dig("facts", "state_accesses")).each do |access| + accesses_by_function[access["function_id"]] << access + end Array(evidence["methods"]).each do |m| + accesses = accesses_by_function[m["id"]] meth = { + id: m["id"], + owner_id: m["owner_id"], name: m["name"], signature: m["signature"], parameters: Array(m["params"]), @@ -43,35 +51,40 @@ def self.project_modules(evidence) span: m["span"], file: m["path"], language: m["language"]&.to_sym, - effects: { reads: Set.new, writes: Set.new }, + effects: { + reads: accesses.select { |row| row["kind"] == "reads" }.map { |row| row["field"] }.to_set, + writes: accesses.select { |row| row["kind"] == "writes" }.map { |row| row["field"] }.to_set + }, delegations: [] } methods_by_owner[m["owner"]] << meth + methods_by_id[m["id"]] = meth end # Group fields by owner fields_by_owner = Hash.new { |h, k| h[k] = [] } first_field_by_owner = {} Array(evidence["fields"]).each do |f| - fields_by_owner[f["owner"]] << f["name"] + fields_by_owner[f["owner"]] << f first_field_by_owner[f["owner"]] ||= f end # Index call graph edges (internal calls) internal_calls = Hash.new { |h, k| h[k] = [] } - Array(evidence.dig("facts", "call_graph_edges")).each do |edge| - next unless edge["kind"] == "internal_call" - if edge["source"] =~ /^fn:(.+)#(.+)$/ - edge_owner, func = $1, $2 - if edge["target"] =~ /^fn:.+#(.+)$/ - target_func = $1 - internal_calls["#{edge_owner}##{func}"] << { - receiver: "self", - message: target_func, - type: edge["conditional"] ? :conditional : :always - } - end - end + Array(evidence.dig("facts", "calls")).each do |call| + source = methods_by_id[call["source"]] + next unless source + + target = methods_by_id[call["target"]] + source[:delegations] << { + receiver: call["receiver"].to_s.empty? ? "self" : call["receiver"], + message: target ? target[:name] : call["message"], + line: call["line"]&.to_i, + span: call["span"], + type: call["conditional"] ? :conditional : :always, + confidence: call["confidence"], + unresolved_reason: call["unresolved_reason"] + } end # Map state protocols and param origins to reads/writes and delegations @@ -84,7 +97,6 @@ def self.project_modules(evidence) meths = methods_by_owner[p_owner] || [] meth = meths.find { |m_item| m_item[:name] == func } if meth - meth[:effects][:reads].add(field) meth[:delegations] << { receiver: field, message: proto, @@ -101,9 +113,7 @@ def self.project_modules(evidence) meths = methods_by_owner[o_owner] || [] meth = meths.find { |m_item| m_item[:name] == func } - if meth - meth[:effects][:writes].add(field) - end + meth # parameter origin is metadata, not proof of a write end # Add internal call delegations @@ -126,8 +136,9 @@ def self.project_modules(evidence) line: meta[:line], span: meta[:span], language: meta[:language], - states: fields_by_owner[owner].to_set, - ivar_types: {}, + states: fields_by_owner[owner].map { |field| field["name"] }.to_set, + state_records: fields_by_owner[owner], + ivar_types: fields_by_owner[owner].to_h { |field| [field["name"], field["declared_type"]] }.compact, ivar_properties: {}, methods: methods_by_owner[owner] } @@ -232,10 +243,15 @@ def deduplicate_facts(facts, type_definitions) end def build_payload(facts, files, deduped, state_protocols, state_param_origins) + owners = Array(facts["owners"]) methods = Array(facts["methods"]) fields = Array(facts["fields"]) state_types = facts["state_types"] || {} signatures = facts["signatures"] || {} + calls = Array(facts["calls"]) + state_accesses = Array(facts["state_accesses"]) + call_graph_edges = Array(facts["call_graph_edges"]) + state_type_edges = Array(facts["state_type_edges"]) collection_index_lookups = Array(facts["collection_index_lookups"]) hash_record_blockers = Array(facts["hash_record_blockers"]) tlet_sites = Array(facts["tlet_sites"]) @@ -318,9 +334,14 @@ def build_payload(facts, files, deduped, state_protocols, state_param_origins) "target_exclude_dirs" => Espalier.target_exclude_dirs(root: @root).map { |dir| rel(dir) }, "runtime_fields" => false, "files" => files.map { |file| file_record(file) }, + "owners" => owners.sort_by { |owner| [owner["path"].to_s, owner["line"].to_i, owner["name"].to_s] }, "fields" => fields.uniq { |field| field["id"] }.sort_by { |field| [field["path"], field["owner"], field["name"]] }, "methods" => methods.sort_by { |method| [method["path"], method["owner"], method["line"].to_i, method["name"]] }, "facts" => { + "calls" => calls.sort_by { |call| [call["path"].to_s, call["line"].to_i, call["id"].to_s] }, + "state_accesses" => state_accesses.sort_by { |access| [access["path"].to_s, access["line"].to_i, access["id"].to_s] }, + "call_graph_edges" => call_graph_edges.sort_by { |edge| [edge["source"].to_s, edge["target"].to_s, edge["kind"].to_s] }, + "state_type_edges" => state_type_edges.sort_by { |edge| [edge["source"].to_s, edge["target"].to_s, edge["label"].to_s] }, "state_types" => Hash[state_types.sort], "state_type_records" => deduped[:state_type_records].sort_by { |r| [r["language"].to_s, r["path"].to_s, r["owner"].to_s, r["field"].to_s] }, "state_protocols" => state_protocols, @@ -358,8 +379,11 @@ def build_payload(facts, files, deduped, state_protocols, state_param_origins) }, "summary" => { "files" => files.size, + "owners" => owners.size, "methods" => methods.size, "fields" => fields.uniq { |field| field["id"] }.size, + "calls" => calls.size, + "state_accesses" => state_accesses.size, "signatures" => typed_signature_count, "state_types" => state_types.size, "state_type_records" => deduped[:state_type_records].size, diff --git a/gems/espalier/lib/espalier/structural_big_o.rb b/gems/espalier/lib/espalier/structural_big_o.rb index 87ded228d..47ed937d1 100644 --- a/gems/espalier/lib/espalier/structural_big_o.rb +++ b/gems/espalier/lib/espalier/structural_big_o.rb @@ -10,6 +10,7 @@ class StructuralBigO LOOP_START = / \b(?:while|until)\b| + ^\s*for\b| \bloop\s+do\b| \.(?:each|each_key|each_value|each_with_index|map|map!|select|reject|filter|filter_map|flat_map|sort_by|reverse_each)\b /x.freeze @@ -32,18 +33,54 @@ def hints_for(file, method, owner) slice = lines[(start_line - 1)..(end_line - 1)] || [] loops = loop_ranges(slice, start_line) + + constants = Set.new + lines.each do |line| + if line =~ /\b(?:const\s+([A-Z_a-z]\w*)|([A-Z][A-Z_0-9]*)\s*=)/ + constants.add($1 || $2) + end + end + hints = [] - hints.concat(polynomial_loop_hints(lines, loops)) + hints.concat(polynomial_loop_hints(lines, loops, start_line, constants)) hints.concat(recursion_hints(lines, method, start_line, end_line)) loops.each do |loop_range| + classification = classify_loop(loop_range[:text], loop_range[:start], start_line, lines, constants) + is_dynamic = classification[:is_dynamic] + trigger = classification[:trigger] + if fixpoint_loop?(lines, loop_range) - hints.concat(fixpoint_collection_hints(lines, loop_range)) - hints.concat(project_call_hints(lines, loop_range, owner)) + hints.concat( + fixpoint_collection_hints(lines, loop_range).map do |h| + h.merge(is_dynamic: is_dynamic, trigger: trigger) + end + ) + hints.concat( + project_call_hints(lines, loop_range, owner).map do |h| + h.merge(is_dynamic: is_dynamic, trigger: trigger) + end + ) end - hints.concat(expensive_project_call_hints(lines, loop_range, owner, method[:name].to_s)) - hints.concat(aggregate_scan_hints(lines, loop_range)) - hints.concat(shifting_insert_hints(lines, loop_range)) - hints.concat(graph_traversal_hints(lines, loops, loop_range)) + hints.concat( + expensive_project_call_hints(lines, loop_range, owner, method[:name].to_s).map do |h| + h.merge(is_dynamic: is_dynamic, trigger: trigger) + end + ) + hints.concat( + aggregate_scan_hints(lines, loop_range).map do |h| + h.merge(is_dynamic: is_dynamic, trigger: trigger) + end + ) + hints.concat( + shifting_insert_hints(lines, loop_range).map do |h| + h.merge(is_dynamic: is_dynamic, trigger: trigger) + end + ) + hints.concat( + graph_traversal_hints(lines, loops, loop_range).map do |h| + h.merge(is_dynamic: is_dynamic, trigger: trigger) + end + ) end hints.uniq { |hint| [hint[:line], hint[:reason], hint[:operation]] } end @@ -152,19 +189,23 @@ def fixpoint_loop?(lines, loop_range) body.match?(/\bbreak\s+unless\s+(?:#{FIXPOINT_NAMES.join("|")})\b/) end - def polynomial_loop_hints(lines, loops) + def polynomial_loop_hints(lines, loops, start_line, constants) block_loops = loops.reject { |loop_range| loop_range[:inline] } block_loops.filter_map do |loop_range| depth = loop_depth(block_loops, loop_range) next if depth < 2 next if containing_fixpoint_loop?(lines, block_loops, loop_range) + classification = classify_loop(loop_range[:text], loop_range[:start], start_line, lines, constants) + structural_hint( line: loop_range[:start], complexity: polynomial_complexity(depth), operation: loop_range[:text], reason: "nested loop containment depth #{depth}", - detail: lines[loop_range[:start] - 1].to_s.strip + detail: lines[loop_range[:start] - 1].to_s.strip, + is_dynamic: classification[:is_dynamic], + trigger: classification[:trigger] ) end end @@ -196,6 +237,9 @@ def recursion_hints(lines, method, start_line, end_line) structural_hint( line: recursive_calls.first, complexity: "O(N!)", + space: "O(N)", + is_dynamic: true, + trigger: "line #{start_line}", operation: method_name, reason: "recursive branching over shrinking collection", detail: lines[recursive_calls.first - 1].to_s.strip @@ -203,19 +247,73 @@ def recursion_hints(lines, method, start_line, end_line) ] end - return [] unless exponential_recursion?(lines, recursive_calls) + if exponential_recursion?(lines, recursive_calls) + return [ + structural_hint( + line: recursive_calls.first, + complexity: "O(2^N)", + space: "O(N)", + is_dynamic: true, + trigger: "line #{start_line}", + operation: method_name, + reason: "multiple recursive branches", + detail: lines[recursive_calls.first - 1].to_s.strip + ) + ] + end + + if recursive_calls.length >= 2 && halving_recursion?(lines, recursive_calls) + return [ + structural_hint( + line: recursive_calls.first, + complexity: "O(N)", + space: "O(log N)", + is_dynamic: true, + trigger: "line #{start_line}", + operation: method_name, + reason: "multiple recursive branches over divided input", + detail: lines[recursive_calls.first - 1].to_s.strip + ) + ] + end + + if halving_recursion?(lines, recursive_calls) + return [ + structural_hint( + line: recursive_calls.first, + complexity: "O(log N)", + space: "O(log N)", + is_dynamic: true, + trigger: "line #{start_line}", + operation: method_name, + reason: "recursive call with division / halving", + detail: lines[recursive_calls.first - 1].to_s.strip + ) + ] + end + # Linear recursion fallback [ structural_hint( line: recursive_calls.first, - complexity: "O(2^N)", + complexity: "O(N)", + space: "O(N)", + is_dynamic: true, + trigger: "line #{start_line}", operation: method_name, - reason: "multiple recursive branches", + reason: "recursive self call", detail: lines[recursive_calls.first - 1].to_s.strip ) ] end + def halving_recursion?(lines, recursive_calls) + recursive_calls.any? do |line_no| + line = lines[line_no - 1].to_s + line.match?(%r{/\s*2\b|>>\s*1\b|\bsplit\b|\bslice\b|mid\b}) + end + end + def recursive_call_lines(lines, method_name, start_line, end_line) escaped = Regexp.escape(method_name) ((start_line + 1)..end_line).flat_map do |line_no| @@ -421,11 +519,14 @@ def indent_width(line) line[/\A\s*/].to_s.length end - def structural_hint(line:, operation:, reason:, detail:, complexity: "O(N^2)") + def structural_hint(line:, operation:, reason:, detail:, complexity: "O(N^2)", space: nil, is_dynamic: true, trigger: nil) { type: :structural, line: line, complexity: complexity, + space: space, + is_dynamic: is_dynamic, + trigger: trigger, operation: operation, reason: reason, detail: detail @@ -483,5 +584,65 @@ def polynomial_parts(complexity) [0, false] end end + + def classify_loop(loop_line, loop_line_no, start_line, lines, constants) + trigger_var = extract_bound_variable(loop_line) + return { is_dynamic: false, trigger: nil } if trigger_var.nil? || constants.include?(trigger_var) + + def_line = find_variable_definition_line(lines, trigger_var, loop_line_no, start_line) + if def_line && fixed_bound_definition?(lines[def_line - 1].to_s, trigger_var) + return { is_dynamic: false, trigger: nil } + end + + { is_dynamic: true, trigger: def_line ? "line #{def_line}" : nil } + end + + def extract_bound_variable(loop_line) + # 1. for x in collection / for x in &collection + if loop_line =~ /\bin\s+&?(?:mut\s+)?([a-zA-Z_]\w*)\b/ + return $1 + end + # 2. for x := range collection + if loop_line =~ /\brange\s+([a-zA-Z_]\w*)\b/ + return $1 + end + # 3. i < limit / i <= n / j < len(users) + if loop_line =~ /(?:<=|>=|<|>)\s*(?:len|length|size)\s*\(\s*([a-zA-Z_]\w*)\b/ + return $1 + end + if loop_line =~ /(?:<=|>=|<|>)\s*([a-zA-Z_]\w*)\b/ + return $1 + end + # 4. collection.each / collection.map + if loop_line =~ /\b([a-zA-Z_]\w*)\.(?:each|each_key|each_value|each_with_index|map|map!|select|reject|filter|filter_map|flat_map|sort_by)\b/ + return $1 + end + + # Fallback: first non-ignored token + tokens = loop_line.scan(/[a-zA-Z_]\w*/) + ignored_tokens = %w[for while until do each times loop in range let mut var int i j k class module def end return true false] + other_tokens = tokens - ignored_tokens + other_tokens.first + end + + def find_variable_definition_line(lines, var_name, loop_line_no, start_line) + (loop_line_no - 1).downto(start_line) do |idx| + line = lines[idx - 1].to_s + if line =~ /\b#{Regexp.escape(var_name)}\s*(?:=(?!=)|\+=|-=|:=)/ + return idx + end + if idx == start_line + if line =~ /\b#{Regexp.escape(var_name)}\b/ + return idx + end + end + end + nil + end + + def fixed_bound_definition?(line, var_name) + escaped = Regexp.escape(var_name) + line.match?(/\b#{escaped}\s*(?:=(?!=)|:=)\s*-?\d+(?:\.\d+)?\b/) + end end end diff --git a/gems/espalier/test/architecture_artifact_test.rb b/gems/espalier/test/architecture_artifact_test.rb new file mode 100644 index 000000000..136915e3b --- /dev/null +++ b/gems/espalier/test/architecture_artifact_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../lib/espalier" + +class ArchitectureArtifactTest < Minitest::Test + def test_projects_first_class_state_edges_and_citations + evidence = { + "root" => "/repo", + "owners" => [{ "id" => "owner:1", "name" => "Demo", "kind" => "class", "language" => "ruby", "path" => "/repo/demo.rb", "line" => 1, "span" => [1, 0, 8, 3] }], + "methods" => [{ "id" => "fn:1", "owner_id" => "owner:1", "owner" => "Demo", "name" => "run", "visibility" => "private", "language" => "ruby", "path" => "/repo/demo.rb", "line" => 2, "span" => [2, 0, 5, 3] }], + "fields" => [{ "id" => "state:1", "owner_id" => "owner:1", "owner" => "Demo", "name" => "@value", "language" => "ruby", "path" => "/repo/demo.rb", "line" => 3, "span" => [3, 2, 3, 8] }], + "facts" => { + "calls" => [], + "state_accesses" => [{ "id" => "edge:1", "function_id" => "fn:1", "state_id" => "state:1", "owner" => "Demo", "function" => "run", "field" => "@value", "receiver" => "self", "kind" => "writes", "path" => "/repo/demo.rb", "line" => 3, "span" => [3, 2, 3, 8], "conditional" => false, "confidence" => "high" }] + } + } + + artifact = Espalier::ArchitectureArtifact.build(evidence, root: "/repo", commit: "abc") + assert_equal "espalier.architecture.v1", artifact["kind"] + assert_equal %w[function owner state], artifact["nodes"].map { |node| node["kind"] }.sort + edge = artifact["edges"].fetch(0) + assert_equal ["fn:1", "state:1", "writes"], edge.values_at("source", "target", "kind") + assert_equal "demo.rb", edge.dig("spans", 0, "path") + function = artifact["nodes"].find { |node| node["id"] == "fn:1" } + assert_equal "private", function.dig("metadata", "visibility") + end +end diff --git a/gems/espalier/test/big_o_test.rb b/gems/espalier/test/big_o_test.rb index 93a051f77..e1aae3a79 100644 --- a/gems/espalier/test/big_o_test.rb +++ b/gems/espalier/test/big_o_test.rb @@ -7,7 +7,7 @@ class BigOTest < Minitest::Test def test_multiply_complexity analyzer = Espalier::BigOAnalyzer.new - + # Basic multiplications assert_equal "O(N)", analyzer.send(:multiply_complexity, "O(1)", "O(N)") assert_equal "O(N)", analyzer.send(:multiply_complexity, "O(N)", "O(1)") @@ -62,17 +62,17 @@ def test_analyze_method nil_kill_evidence = { "10" => { "items" => "Array" } } - + analyzer = Espalier::BigOAnalyzer.new(nil_kill_evidence: nil_kill_evidence) - + ast_mock = [ { type: :call, receiver: "items", method: "sort", line: 10 }, { type: :loop, line: 11 }, { type: :callback, line: 12 } ] - + result = analyzer.analyze_method("process_items", ast_mock) - + assert_equal "process_items", result[:method] assert_equal "O(N log N)", result[:lower_bound_complexity] assert_empty result[:unknown_operations] @@ -205,4 +205,243 @@ def test_structural_hint_ranks_super_polynomial_work assert_equal "O(N!)", result[:lower_bound_complexity] assert_equal 3, result[:warnings].size end + + def test_space_complexity_calculation + analyzer = Espalier::BigOAnalyzer.new + + # 1. Default/pure iterative space + result_default = analyzer.analyze_method("iterative", [ + { type: :loop, line: 10 } + ]) + assert_equal "O(1)", result_default[:space_complexity] + + # 2. Divide and conquer space + result_dc = analyzer.analyze_method("binary_search", [ + { type: :structural, line: 10, complexity: "O(log N)", space: "O(log N)", operation: "search" } + ]) + assert_equal "O(log N)", result_dc[:space_complexity] + + # 3. Linear recursion space + result_linear = analyzer.analyze_method("factorial", [ + { type: :structural, line: 10, complexity: "O(N)", space: "O(N)", operation: "factorial" } + ]) + assert_equal "O(N)", result_linear[:space_complexity] + end + + def test_recursion_detection_types + require_relative "../lib/espalier/structural_big_o" + + # Mock source cache + source_cache = { + "fact.rb" => [ + "def factorial(n)\n", + " return 1 if n <= 1\n", + " n * factorial(n - 1)\n", + "end\n" + ], + "fib.rb" => [ + "def fib(n)\n", + " return n if n <= 1\n", + " fib(n - 1) + fib(n - 2)\n", + "end\n" + ], + "dc.rb" => [ + "def bsearch(n)\n", + " return if n <= 1\n", + " bsearch(n / 2)\n", + "end\n" + ], + "branching_dc.rb" => [ + "def split_search(n)\n", + " return if n <= 1\n", + " split_search(n / 2)\n", + " split_search(n / 2)\n", + "end\n" + ], + "perm.rb" => [ + "def permute(arr)\n", + " arr.each do |x|\n", + " permute(arr - [x])\n", + " end\n", + "end\n" + ] + } + + s = Espalier::StructuralBigO.new(source_cache: source_cache) + + # 1. Linear recursion: factorial(n - 1) + hints = s.hints_for("fact.rb", { name: "factorial", line: 1, span: [1, 0, 4, 3] }, "Math") + assert_equal 1, hints.size + assert_equal "O(N)", hints[0][:complexity] + assert_equal "O(N)", hints[0][:space] + assert_equal true, hints[0][:is_dynamic] + assert_equal "line 1", hints[0][:trigger] + + # Two half-size calls visit a linear number of recursion nodes; classifying + # this as logarithmic ignores the second branch. + hints = s.hints_for("branching_dc.rb", { name: "split_search", line: 1, span: [1, 0, 5, 3] }, "Math") + assert_equal 1, hints.size + assert_equal "O(N)", hints[0][:complexity] + assert_equal "O(log N)", hints[0][:space] + + # 2. Exponential recursion: fib(n - 1) + fib(n - 2) + hints = s.hints_for("fib.rb", { name: "fib", line: 1, span: [1, 0, 4, 3] }, "Math") + assert_equal 1, hints.size + assert_equal "O(2^N)", hints[0][:complexity] + assert_equal "O(N)", hints[0][:space] + assert_equal true, hints[0][:is_dynamic] + assert_equal "line 1", hints[0][:trigger] + + # 3. Divide and conquer: bsearch(n / 2) + hints = s.hints_for("dc.rb", { name: "bsearch", line: 1, span: [1, 0, 4, 3] }, "Math") + assert_equal 1, hints.size + assert_equal "O(log N)", hints[0][:complexity] + assert_equal "O(log N)", hints[0][:space] + assert_equal true, hints[0][:is_dynamic] + assert_equal "line 1", hints[0][:trigger] + + # 4. Factorial recursion: permute(arr - [x]) in loop + hints = s.hints_for("perm.rb", { name: "permute", line: 1, span: [1, 0, 5, 3] }, "Math") + # Might include both permute (O(N!)) and collection scan / expensive call hints. + factorial_hint = hints.find { |h| h[:complexity] == "O(N!)" } + refute_nil factorial_hint + assert_equal "O(N)", factorial_hint[:space] + assert_equal true, factorial_hint[:is_dynamic] + assert_equal "line 1", factorial_hint[:trigger] + end + + def test_loop_complexity_classification_and_trigger_tracking + require_relative "../lib/espalier/structural_big_o" + + source_cache = { + "demo.rb" => [ + "def process(n)\n", + " limit = 100\n", + " 5.times do |i|\n", + " (0..limit).each do |j|\n", + " (0..n).each do |k|\n", + " # nested loops\n", + " end\n", + " end\n", + " end\n", + "end\n" + ], + "demo_const.rb" => [ + "LIMIT = 100\n", + "def run()\n", + " (0..LIMIT).each do |i|\n", + " (0..10).each do |j|\n", + " # nested\n", + " end\n", + " end\n", + "end\n" + ] + } + + s = Espalier::StructuralBigO.new(source_cache: source_cache) + + # 1. Test process(n): + # - 5.times -> static + # - (0..limit) -> static (limit = 100 constant assignment) + # - (0..n) -> dynamic (n parameter) + hints = s.hints_for("demo.rb", { name: "process", line: 1, span: [1, 0, 10, 3] }, "Demo") + nested_hint = hints.find { |h| h[:complexity] == "O(N^2)" } + refute_nil nested_hint + assert_equal true, nested_hint[:is_dynamic] + assert_equal "line 1", nested_hint[:trigger] # 'n' parameter defined on line 1 + + # Check loop classifications manually + constants = Set.new + lines = source_cache["demo.rb"] + c1 = s.send(:classify_loop, "5.times do |i|", 3, 1, lines, constants) + assert_equal false, c1[:is_dynamic] + + c2 = s.send(:classify_loop, "(0..limit).each do |j|", 4, 1, lines, constants) + assert_equal false, c2[:is_dynamic] + + c3 = s.send(:classify_loop, "(0..n).each do |k|", 5, 1, lines, constants) + assert_equal true, c3[:is_dynamic] + assert_equal "line 1", c3[:trigger] + + # 2. Test demo_const.rb: + # - LIMIT is defined as a constant + # - (0..LIMIT).each -> static + # - (0..10).each -> static + hints2 = s.hints_for("demo_const.rb", { name: "run", line: 2, span: [2, 0, 8, 3] }, "DemoConst") + nested_hint2 = hints2.find { |h| h[:complexity] == "O(N^2)" } + refute_nil nested_hint2 + assert_equal false, nested_hint2[:is_dynamic] + assert_nil nested_hint2[:trigger] + + # 3. Test Go loops: + go_lines = [ + "func process(n int) {\n", + "\tlimit := 10\n", + "\tfor i := 0; i < limit; i++ {\n", + "\t\tfor j := 0; j < n; j++ {\n", + "\t\t\t// nested\n", + "\t\t}\n", + "\t}\n", + "}\n" + ] + go_constants = Set.new(["limit"]) + # - Loop 1: i < limit -> static + g1 = s.send(:classify_loop, "for i := 0; i < limit; i++ {", 3, 1, go_lines, go_constants) + assert_equal false, g1[:is_dynamic] + # - Loop 2: j < n -> dynamic + g2 = s.send(:classify_loop, "for j := 0; j < n; j++ {", 4, 1, go_lines, go_constants) + assert_equal true, g2[:is_dynamic] + assert_equal "line 1", g2[:trigger] # parameter 'n' defined on line 1 + + source_cache["go_case.go"] = go_lines + go_hints = s.hints_for( + "go_case.go", + { name: "process", line: 1, span: [1, 0, 8, 1] }, + "Go" + ) + go_nested = go_hints.find { |hint| hint[:complexity] == "O(N^2)" } + refute_nil go_nested + assert_equal true, go_nested[:is_dynamic] + assert_equal "line 1", go_nested[:trigger] + + # 4. Test Rust loops: + rust_lines = [ + "fn run(items: Vec) {\n", + "\tconst LIMIT: usize = 5;\n", + "\tfor i in 0..LIMIT {\n", + "\t\tfor item in &items {\n", + "\t\t\t// nested\n", + "\t\t}\n", + "\t}\n", + "}\n" + ] + rust_constants = Set.new(["LIMIT"]) + # - Loop 1: 0..LIMIT -> static + r1 = s.send(:classify_loop, "for i in 0..LIMIT {", 3, 1, rust_lines, rust_constants) + assert_equal false, r1[:is_dynamic] + # - Loop 2: in &items -> dynamic + r2 = s.send(:classify_loop, "for item in &items {", 4, 1, rust_lines, rust_constants) + assert_equal true, r2[:is_dynamic] + assert_equal "line 1", r2[:trigger] # parameter 'items' defined on line 1 + + # 5. Test Java/C loops: + c_lines = [ + "void run(int n) {\n", + "\tconst int LIMIT = 8;\n", + "\tfor (int i = 0; i < LIMIT; i++) {\n", + "\t\tfor (int j = 0; j < n; j++) {\n", + "\t\t\t// nested\n", + "\t\t}\n", + "\t}\n", + "}\n" + ] + c_constants = Set.new(["LIMIT"]) + # - Loop 1: i < LIMIT -> static + c1 = s.send(:classify_loop, "for (int i = 0; i < LIMIT; i++) {", 3, 1, c_lines, c_constants) + assert_equal false, c1[:is_dynamic] + # - Loop 2: j < n -> dynamic + c2 = s.send(:classify_loop, "for (int j = 0; j < n; j++) {", 4, 1, c_lines, c_constants) + assert_equal true, c2[:is_dynamic] + assert_equal "line 1", c2[:trigger] # parameter 'n' defined on line 1 + end end diff --git a/gems/fact-mine/docs/agents/iterator-invalidation-reliability.md b/gems/fact-mine/docs/agents/iterator-invalidation-reliability.md new file mode 100644 index 000000000..0e0166d70 --- /dev/null +++ b/gems/fact-mine/docs/agents/iterator-invalidation-reliability.md @@ -0,0 +1,186 @@ +# Iterator Invalidation: Reliability Assessment and Fact-Mine Design + +Status: **Do not implement the proposed cross-language detector.** + +This decision covers the proposal in +`iterator_invalidation_plan.md` to identify a loop collection, follow arguments +through a `(method, parameter-index)` graph, and use per-language mutation-name +registries to report iterator invalidation. That design cannot be made reliable +for all languages supported by FactMine without adding language compiler front +ends, type and symbol resolution, points-to/alias analysis, and container-specific +semantic models. The likely value does not justify that second analysis stack. + +The supported set is Ruby, Python, JavaScript, Java, TypeScript, Swift, Kotlin, +Go, Rust, Zig, Lua, C, C++, C#, and PHP. Tree-sitter and normalized syntax make +their *shapes* comparable; they do not make their iteration semantics equivalent. + +## Decision and terminology + +Do not add `stdlib_mutations.yml`, `DirectMutation`, +`ParameterPropagation`, or a generic reachability detector as proposed. + +The proposal combines three different conditions under one name: + +1. **Iterator invalidation:** an iterator, pointer, reference, or index ceases to + be valid, potentially causing undefined behavior or a runtime exception. +2. **Mutation during traversal:** traversal remains valid, but the language + specifies that inserted/deleted elements may be visited, skipped, or observed. +3. **Algorithmic surprise:** the program is valid and specified, but modifying + the traversed collection is hard to reason about. + +Only the first is iterator invalidation. A generic "iterated value reaches a +mutating method" test cannot distinguish them and would mislabel many valid Go, +JavaScript, PHP, Ruby, Python, and language-specific collection idioms. + +Reliability here means that a finding proves all of the following: + +- which runtime object is being traversed; +- which iterator/traversal contract applies to that object; +- that the mutation reaches the same object, including through aliases and + calls; +- which mutation operation occurs and whether it invalidates this iterator; +- that no snapshot, copy, rebinding, copy-on-write collection, permitted + iterator mutation, or implementation-specific contract makes it safe; and +- that the condition is not already rejected by the language compiler. + +FactMine cannot currently prove those statements. + +## Why the proposed facts are insufficient + +Current facts do not expose a loop entity or the identity of its iterable. +`NormalizedExtractor::scan_loop` only scans descendants under the control label +`"iterates"`. `CallSite.arguments` and receivers are strings, not resolved value +IDs. `FunctionDef.body` and normalized nodes are intentionally private to +FactMine passes. `MethodSummary` provides statement-level local reads/writes, +not heap-object identity, iterator creation, aliases, points-to sets, or resolved +callees. + +A graph keyed by `(method name, parameter index)` therefore has fundamental +collisions: + +- overloads, methods with the same name in different owners, extensions, traits, + interfaces, monkeypatches, and dynamic dispatch; +- receiver mutation versus argument mutation; +- rebinding a parameter versus mutating the object it refers to; +- field, index, pointer, slice, view, and closure-captured aliases; +- callbacks/yields whose bodies run inside library traversal; +- generic functions whose behavior depends on the concrete collection; +- same-file versus cross-file definitions and external dependencies; and +- native/reflection/FFI calls with unavailable bodies. + +A mutation-name YAML file does not repair this. Names are not semantic identities: +`append` is a builtin in Go, a method in several languages, user-definable in +dynamic languages, and can rebind or return a new value rather than mutate the +traversed object. C and C++ mutations often have no method call at all. + +## Per-language feasibility + +This matrix is deliberately conservative. “Possible narrowly” means only with +the named compiler/type service and a versioned model of known library +collections; it does not mean FactMine's current syntax facts are sufficient. + +| Language | Semantic obstacle | Reliable repository-wide detector | +| --- | --- | --- | +| Ruby | Duck typing, aliases, open classes, `each`/yield callbacks, and collection-specific behavior; some mutations raise while others produce defined or surprising traversal | No; local syntactic lint only | +| Python | Dynamic dispatch and aliases; list traversal differs from dict/set size-change checks; user iterators define their own contract | No; local syntactic lint only | +| JavaScript | Array iteration methods, indexed loops, and live Map/Set iterators have different specified behavior; prototypes and proxies are dynamic | No; many mutations are not invalidation | +| TypeScript | Types improve symbol resolution but erase at runtime; JavaScript prototype/proxy and iterator semantics remain | Possible narrowly with the TypeScript compiler API, not Tree-sitter alone | +| Java | Overloads and receiver types must be resolved; fail-fast behavior is generally best-effort and collection/iterator implementations vary | Possible narrowly with javac/JDT plus JDK collection models | +| Swift | Collection indices and mutation guarantees are type-specific; value semantics, copy-on-write, exclusivity, and custom `Collection` conformances matter | Possible narrowly with Swift compiler semantic information | +| Kotlin | Dispatch and types require compiler resolution, and semantics can differ across JVM, JS, and Native targets | No single source-level policy for all targets | +| Go | `range` behavior is specified separately for arrays, slices, maps, channels, integers, and iterator functions; map deletion/addition and slice append are not one invalidation rule | Usually not an invalidation defect; do not report generic mutation | +| Rust | Safe borrowing rejects the ordinary same-collection structural mutation case; interior mutability, unsafe code, custom iterators, and reallocation require compiler/MIR-level reasoning | Little incremental value; rely on rustc/Clippy/Miri | +| Zig | Arrays, slices, pointers, allocators, and container APIs expose different lifetime/reallocation behavior; aliases are explicit but require semantic and points-to analysis | No with syntax facts; compiler-level work required | +| Lua | Table traversal and mutation are dynamic and library/user iterators are ordinary functions/closures | No; local syntactic lint only | +| C | Iteration is encoded as pointer/index/control flow; there is no universal container or mutation API; invalidation depends on allocator and library contracts | No general detector; needs whole-program points-to analysis and annotations | +| C++ | Standard container, operation, iterator category, allocator, range/view, and language-version rules form a large matrix; custom containers add arbitrary contracts | Possible narrowly with Clang AST/CFG and versioned standard-library models | +| C# | `foreach` lowering and resolved enumerator types matter; standard collections often version-check, while custom enumerators define arbitrary behavior | Possible narrowly with Roslyn and BCL models | +| PHP | `foreach` by-value/by-reference behavior, copy-on-write, arrays versus Traversable objects, and dynamic calls differ | No; local syntactic lint only | + +Even the “possible narrowly” entries would create a compiler-integration product, +not a modest new FactMine pass. Supporting all 15 at a common reliability level +is not realistic. + +## If the decision is revisited + +First validate demand with a precision-first experiment in one language and one +container family. C++ standard containers with Clang, Java JDK collections with +javac/JDT, or C# BCL collections with Roslyn are reasonable experiments because +resolved types and documented collection contracts exist. Do not start with a +cross-language abstraction. + +The experiment must use an explicit semantic fact contract, not method-name +registries: + +```text +IterationSite + id, file, span, function_symbol + iterable_value_id, iterator_value_id + resolved_iteration_contract + +MutationSite + id, file, span, function_symbol + target_value_id, operation_symbol + effect = element_write | structural_change | reallocate | unknown + +AliasEdge + from_value_id, to_value_id, kind, confidence + +CallEdge + caller_symbol, call_site_id, callee_symbol, dispatch_kind, confidence + +ParameterEffectSummary + function_symbol, parameter_index + effect, path_condition, confidence + +IteratorInteraction + iteration_id, mutation_id + outcome = invalidated | runtime_fail_fast | traversal_changes | safe | unknown + contract_source, confidence +``` + +Rules for such a prototype: + +- Language-owned semantic providers resolve symbols and assign iteration and + mutation contracts. Generic FactMine code must not branch on a language. +- Stable symbol/value IDs replace textual method names, receiver names, and + argument strings. +- Summaries are computed to a fixed point over resolved call-graph strongly + connected components. An unqualified DFS over names is forbidden. +- Aliasing includes assignments, fields, indexing/views, closure captures, + pointer/reference operations, receiver-to-parameter flow, and returns. +- Unknown dispatch, unknown container type, external body, FFI, reflection, or + imprecise aliasing yields `unknown`, not a user-visible invalidation finding. +- A reported result must carry the exact collection contract and invalidating + operation that justify it. +- Facts remain detector-neutral and serialize through `Document`; Decomplex may + not inspect source, normalized nodes, `Document.language`, or compiler ASTs. + +This requires changes in order: + +1. Write exact positive and negative source fixtures, including aliasing, + overloads, callbacks, copies/snapshots, custom collections, allowed iterator + removal, recursion, unresolved dependencies, and concurrency. +2. Integrate the chosen language's semantic compiler service behind a + language-owned provider. +3. Add the semantic facts and exact source-fact oracles to FactMine. +4. Measure precision on real repositories before adding a Decomplex consumer. +5. Proceed only if reviewed findings meet a predeclared precision threshold + (recommended: at least 95%) and reveal defects not already caught by the + compiler or standard linters. + +Do not claim support for a language until every applicable fixture category is +implemented. Unsupported/unknown cases must be explicit and silent by default. + +## Final recommendation + +Do not implement this as an all-language Decomplex detector. The work needed for +correctness is very large, the common abstraction is weaker than the languages' +actual contracts, dynamic-language precision would remain poor, and Rust removes +the flagship case at compile time. A low-cost textual implementation would add +noise under a dangerously strong “iterator invalidation” label. + +If there is still product interest, run one compiler-backed, standard-library-only +experiment and treat it as a separate feasibility project. Until that experiment +demonstrates unique high-precision value, FactMine should add no facts for this +proposal. diff --git a/gems/fact-mine/src/profile.rs b/gems/fact-mine/src/profile.rs index 493d62502..9713bf9b2 100644 --- a/gems/fact-mine/src/profile.rs +++ b/gems/fact-mine/src/profile.rs @@ -38,6 +38,8 @@ pub enum Profile { /// The enriched output matching what Ruby's EspalierProfile::Builder.build returns. #[derive(Clone, Debug, Serialize, Default)] pub struct ProfileOutput { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub owners: Vec, pub methods: Vec, pub fields: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -61,6 +63,13 @@ pub struct ProfileOutput { /// Internal call edges between functions in the same owner. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub call_graph_edges: Vec, + /// Lossless normalized call sites. Espalier resolves cross-file targets + /// after all files have been merged. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub calls: Vec, + /// Direct function/state relationships from normalized extraction. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub state_accesses: Vec, // NilKill-only fields #[serde(default, skip_serializing_if = "Vec::is_empty")] pub collection_index_lookups: Vec, @@ -102,6 +111,55 @@ pub struct ProfileOutput { pub struct_field_array_shapes: BTreeMap, } +#[derive(Clone, Debug, Serialize)] +pub struct OwnerRecord { + pub id: String, + pub name: String, + pub kind: String, + pub language: String, + pub path: String, + pub line: usize, + pub span: [usize; 4], + pub confidence: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct CallRecord { + pub id: String, + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + pub kind: String, + pub owner: String, + pub function: String, + pub receiver: String, + pub message: String, + pub path: String, + pub line: usize, + pub span: [usize; 4], + pub conditional: bool, + pub confidence: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub unresolved_reason: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StateAccessRecord { + pub id: String, + pub function_id: String, + pub state_id: String, + pub owner: String, + pub function: String, + pub field: String, + pub receiver: String, + pub kind: String, + pub path: String, + pub line: usize, + pub span: [usize; 4], + pub conditional: bool, + pub confidence: String, +} + #[derive(Clone, Debug, Serialize)] pub struct CallGraphEdge { pub source: String, @@ -126,6 +184,8 @@ pub struct StateTypeEdge { #[derive(Clone, Debug, Serialize)] pub struct MethodRecord { + pub id: String, + pub owner_id: String, pub key: Vec, pub owner: String, pub name: String, @@ -135,7 +195,18 @@ pub struct MethodRecord { pub span: Option<[usize; 4]>, pub language: String, pub signature: String, + pub visibility: String, + pub local_complexity: f64, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub complexity_signals: BTreeMap, pub params: Vec, + /// Exact source covered by the parser's function span. Consumers that need + /// function bodies must use this projection rather than re-parsing files. + pub raw_source: String, + /// A deterministic, formatting-insensitive projection for experiment and + /// indexing consumers. This is intentionally lexical normalization, not a + /// replacement for FactMine's normalized structural facts. + pub normalized_source: String, #[serde(skip_serializing_if = "Vec::is_empty")] pub untraceable_params: Vec, pub source: serde_json::Value, @@ -147,6 +218,7 @@ pub struct FieldRecord { pub language: String, pub path: String, pub owner: String, + pub owner_id: String, pub name: String, pub line: usize, pub span: Option<[usize; 4]>, @@ -270,6 +342,7 @@ pub fn extract(document: &Document, profile: Profile) -> ProfileOutput { .map(|l| l.to_string()) .collect::>(); + let owners = extract_owners(document, &language, &path); let methods = extract_methods(&lines, document, &language, &path); let fields = extract_fields(document, &language, &path); let (state_types, mut state_type_records) = extract_state_types(document, &language, &path); @@ -306,6 +379,8 @@ pub fn extract(document: &Document, profile: Profile) -> ProfileOutput { } let state_type_edges = extract_state_type_edges(document, &language, &path); let call_graph_edges = extract_call_graph_edges(document); + let calls = extract_calls(document, &language, &path); + let state_accesses = extract_state_accesses(document, &language, &path); let mut tlet_sites = Vec::new(); let mut dead_nil_checks = Vec::new(); @@ -511,6 +586,7 @@ pub fn extract(document: &Document, profile: Profile) -> ProfileOutput { } ProfileOutput { + owners, methods, fields, struct_declarations, @@ -526,6 +602,8 @@ pub fn extract(document: &Document, profile: Profile) -> ProfileOutput { array_shapes, state_type_edges, call_graph_edges, + calls, + state_accesses, collection_index_lookups, hash_record_blockers, tlet_sites, @@ -551,6 +629,7 @@ pub fn extract(document: &Document, profile: Profile) -> ProfileOutput { /// Merge outputs from multiple files into one (like Ruby's per-file accumulation). pub fn merge(outputs: Vec, profile: Profile) -> ProfileOutput { let nil_kill = profile == Profile::NilKill; + let mut owners = Vec::new(); let mut methods = Vec::new(); let mut fields = Vec::new(); let mut struct_declarations = Vec::new(); @@ -566,6 +645,8 @@ pub fn merge(outputs: Vec, profile: Profile) -> ProfileOutput { let mut array_shapes = Vec::new(); let mut state_type_edges = Vec::new(); let mut call_graph_edges = Vec::new(); + let mut calls = Vec::new(); + let mut state_accesses = Vec::new(); let mut collection_index_lookups = Vec::new(); let mut hash_record_blockers = Vec::new(); let mut tlet_sites = Vec::new(); @@ -587,6 +668,7 @@ pub fn merge(outputs: Vec, profile: Profile) -> ProfileOutput { let mut struct_field_array_shapes = BTreeMap::new(); for output in outputs { + owners.extend(output.owners); methods.extend(output.methods); fields.extend(output.fields); struct_declarations.extend(output.struct_declarations); @@ -608,6 +690,9 @@ pub fn merge(outputs: Vec, profile: Profile) -> ProfileOutput { hash_shapes.extend(output.hash_shapes); array_shapes.extend(output.array_shapes); state_type_edges.extend(output.state_type_edges); + call_graph_edges.extend(output.call_graph_edges); + calls.extend(output.calls); + state_accesses.extend(output.state_accesses); if nil_kill { collection_index_lookups.extend(output.collection_index_lookups); hash_record_blockers.extend(output.hash_record_blockers); @@ -640,7 +725,16 @@ pub fn merge(outputs: Vec, profile: Profile) -> ProfileOutput { .map(|(k, v)| (k, v.into_iter().collect())) .collect(); + owners.sort_by(|a, b| a.id.cmp(&b.id)); + owners.dedup_by(|a, b| a.id == b.id); + call_graph_edges.sort_by(|a, b| a.source.cmp(&b.source).then_with(|| a.target.cmp(&b.target)).then_with(|| a.kind.cmp(&b.kind))); + calls.sort_by(|a, b| a.id.cmp(&b.id)); + calls.dedup_by(|a, b| a.id == b.id); + state_accesses.sort_by(|a, b| a.id.cmp(&b.id)); + state_accesses.dedup_by(|a, b| a.id == b.id); + ProfileOutput { + owners, methods, fields, struct_declarations, @@ -656,6 +750,8 @@ pub fn merge(outputs: Vec, profile: Profile) -> ProfileOutput { array_shapes, state_type_edges, call_graph_edges, + calls, + state_accesses, collection_index_lookups, hash_record_blockers, tlet_sites, @@ -798,8 +894,15 @@ fn extract_methods( let kind = method_kind(fn_def, &owner); let signature = method_signature(lines, fn_def, language); let source = method_source(&signature, language); + let raw_source = source_for_span(lines, fn_def.span); + let normalized_source = raw_source.split_whitespace().collect::>().join(" "); + let complexity = document + .local_complexity_scores + .get(&format!("{}#{}", owner, name)); MethodRecord { + id: function_id(language, path, fn_def), + owner_id: owner_id(language, path, &owner, owner_span(document, &owner)), key: vec![owner.clone(), name.clone(), kind.clone()], owner, name, @@ -809,7 +912,12 @@ fn extract_methods( span: Some(fn_def.span), language: language.to_string(), signature, + visibility: fn_def.visibility.clone().unwrap_or_else(|| "public".to_string()), + local_complexity: complexity.map(|row| row.score).unwrap_or(0.0), + complexity_signals: complexity.map(|row| row.signals.clone()).unwrap_or_default(), params: fn_def.params.clone(), + raw_source, + normalized_source, untraceable_params: extract_untraceable_params(lines, fn_def, language), source, } @@ -817,6 +925,92 @@ fn extract_methods( .collect() } +fn extract_owners(document: &Document, language: &str, path: &str) -> Vec { + let mut owners = document.owner_defs.iter().map(|owner| OwnerRecord { + id: owner_id(language, path, &owner.name, Some(owner.span)), + name: owner.name.clone(), + kind: owner.kind.clone(), + language: language.to_string(), + path: path.to_string(), + line: owner.line, + span: owner.span, + confidence: "high".to_string(), + }).collect::>(); + + // Some grammars attach functions to an implicit/file owner without a + // separate owner definition. Preserve that owner instead of forcing + // Espalier to infer it from display names. + for function in &document.function_defs { + if function.owner.is_empty() || owners.iter().any(|owner| owner.name == function.owner) { + continue; + } + owners.push(OwnerRecord { + id: owner_id(language, path, &function.owner, None), + name: function.owner.clone(), + kind: "owner".to_string(), + language: language.to_string(), + path: path.to_string(), + line: function.line, + span: function.span, + confidence: "partial".to_string(), + }); + } + owners +} + +fn owner_span(document: &Document, owner: &str) -> Option<[usize; 4]> { + document.owner_defs.iter().find(|row| row.name == owner).map(|row| row.span) +} + +fn owner_id(language: &str, path: &str, owner: &str, span: Option<[usize; 4]>) -> String { + stable_id("owner", &[language, path, owner, &span.map(span_key).unwrap_or_default()]) +} + +fn function_id(language: &str, path: &str, function: &syntax::FunctionDef) -> String { + stable_id("fn", &[language, path, &function.owner, &function.name, &function.signature, &span_key(function.span)]) +} + +fn span_key(span: [usize; 4]) -> String { + format!("{}:{}:{}:{}", span[0], span[1], span[2], span[3]) +} + +fn stable_id(prefix: &str, parts: &[&str]) -> String { + // FNV-1a is sufficient here: the unhashed identity components remain in + // the artifact and collisions can be diagnosed. Unlike DefaultHasher its + // result is stable across processes and Rust releases. + let mut hash = 0xcbf29ce484222325_u64; + for part in parts { + for byte in part.as_bytes().iter().chain(std::iter::once(&0_u8)) { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x100000001b3); + } + } + format!("{prefix}:{hash:016x}") +} + +fn source_for_span(lines: &[String], span: [usize; 4]) -> String { + let [start_line, start_column, end_line, end_column] = span; + if start_line == 0 || end_line == 0 || start_line > end_line || end_line > lines.len() { + return String::new(); + } + + let mut selected = lines[start_line - 1..end_line].to_vec(); + if selected.len() == 1 { + let line = &selected[0]; + let start = start_column.min(line.len()); + let end = end_column.min(line.len()).max(start); + return line.get(start..end).unwrap_or_default().to_string(); + } + + if let Some(first) = selected.first_mut() { + *first = first.get(start_column.min(first.len())..).unwrap_or_default().to_string(); + } + if let Some(last) = selected.last_mut() { + *last = last.get(..end_column.min(last.len())).unwrap_or_default().to_string(); + } + selected.join("\n") +} + fn method_kind(fn_def: &syntax::FunctionDef, owner: &str) -> String { if fn_def.name == "initialize" || fn_def.name.starts_with("self.") { "class".to_string() @@ -971,6 +1165,7 @@ fn extract_fields(document: &Document, language: &str, path: &str) -> Vec Vec Vec String { - [language, path, owner, "field", name].join("\u{0}") + stable_id("state", &[language, path, owner, name]) } // --------------------------------------------------------------------------- @@ -2329,6 +2525,128 @@ fn extract_call_graph_edges(document: &Document) -> Vec { merged } +fn source_function_id( + document: &Document, + language: &str, + path: &str, + owner: &str, + function: &str, + line: usize, +) -> String { + let candidates = document.function_defs.iter().filter(|row| { + row.owner == owner && row.name == function + }).collect::>(); + let selected = candidates.iter().copied().find(|row| { + row.span[0] <= line && line <= row.span[2] + }).or_else(|| candidates.first().copied()); + selected.map(|row| function_id(language, path, row)).unwrap_or_else(|| { + stable_id("fn", &[language, path, owner, function]) + }) +} + +fn extract_calls(document: &Document, language: &str, path: &str) -> Vec { + document.call_sites.iter().map(|call| { + let source = source_function_id( + document, language, path, &call.owner, &call.function, call.line, + ); + let implicit = call.receiver.is_empty() || call.receiver == "self" || call.receiver == "this"; + let target_def = implicit.then(|| { + document.function_defs.iter().find(|row| { + row.owner == call.owner && row.name == call.message + }) + }).flatten(); + let target = target_def.map(|row| function_id(language, path, row)); + let state_receiver = document.state_declarations.iter().any(|row| { + call.receiver == row.field + || call.receiver.trim_start_matches('@') == row.field.trim_start_matches('@') + || call.receiver.strip_prefix("self.") == Some(row.field.trim_start_matches('@')) + || call.receiver.strip_prefix("this.") == Some(row.field.trim_start_matches('@')) + }); + let kind = if target.is_some() { + "internal_call" + } else if state_receiver { + "delegation" + } else if implicit { + "unresolved_call" + } else { + "external_call" + }; + let unresolved_reason = if target.is_some() { + None + } else if state_receiver { + Some("state_receiver_requires_corpus_resolution".to_string()) + } else if implicit { + Some("target_not_defined_in_document".to_string()) + } else { + Some("receiver_requires_corpus_resolution".to_string()) + }; + CallRecord { + id: stable_id("edge", &[&source, path, &span_key(call.span), kind, &call.message]), + source, + target, + kind: kind.to_string(), + owner: call.owner.clone(), + function: call.function.clone(), + receiver: call.receiver.clone(), + message: call.message.clone(), + path: path.to_string(), + line: call.line, + span: call.span, + conditional: call.conditional, + confidence: if kind == "internal_call" { "high" } else { "partial" }.to_string(), + unresolved_reason, + } + }).collect() +} + +fn extract_state_accesses(document: &Document, language: &str, path: &str) -> Vec { + let reads = document.state_reads.iter().map(|row| { + state_access_record( + document, language, path, &row.owner, &row.function, &row.field, + &row.receiver, "reads", row.line, row.span, + ) + }); + let writes = document.state_writes.iter().map(|row| { + state_access_record( + document, language, path, &row.owner, &row.function, &row.field, + &row.receiver, "writes", row.line, row.span, + ) + }); + reads.chain(writes).collect() +} + +#[allow(clippy::too_many_arguments)] +fn state_access_record( + document: &Document, + language: &str, + path: &str, + owner: &str, + function: &str, + field: &str, + receiver: &str, + kind: &str, + line: usize, + span: [usize; 4], +) -> StateAccessRecord { + let function_id = source_function_id(document, language, path, owner, function, line); + let state_id = field_id(language, path, owner, field); + StateAccessRecord { + id: stable_id("edge", &[&function_id, &state_id, kind, path, &span_key(span)]), + function_id, + state_id, + owner: owner.to_string(), + function: function.to_string(), + field: field.to_string(), + receiver: receiver.to_string(), + kind: kind.to_string(), + path: path.to_string(), + line, + span, + conditional: false, + confidence: "high".to_string(), + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -3268,6 +3586,31 @@ def py_fn(a: int) -> str: fn test_nil_kill_profile_merge() { test_nil_kill_profile_merge_impl(); } + + #[test] + fn merge_preserves_lossless_relationships() { + let mut output = ProfileOutput::default(); + output.calls.push(CallRecord { + id: "edge:call".into(), source: "fn:a".into(), target: Some("fn:b".into()), + kind: "internal_call".into(), owner: "Demo".into(), function: "a".into(), + receiver: "self".into(), message: "b".into(), path: "demo.rb".into(), line: 2, + span: [2, 0, 2, 3], conditional: false, confidence: "high".into(), unresolved_reason: None, + }); + output.state_accesses.push(StateAccessRecord { + id: "edge:state".into(), function_id: "fn:a".into(), state_id: "state:x".into(), + owner: "Demo".into(), function: "a".into(), field: "x".into(), receiver: "self".into(), + kind: "writes".into(), path: "demo.rb".into(), line: 3, span: [3, 0, 3, 1], + conditional: false, confidence: "high".into(), + }); + output.call_graph_edges.push(CallGraphEdge { + source: "fn:a".into(), target: "fn:b".into(), kind: "internal_call".into(), + label: "internal".into(), conditional: false, weight: 1, + }); + let merged = merge(vec![output], Profile::Espalier); + assert_eq!(merged.calls.len(), 1); + assert_eq!(merged.state_accesses.len(), 1); + assert_eq!(merged.call_graph_edges.len(), 1); + } #[test] fn test_comprehensive_profile_extraction() { test_comprehensive_profile_extraction_impl(); diff --git a/gems/fact-mine/tests/profile_oracle.rs b/gems/fact-mine/tests/profile_oracle.rs index 7d8b62dff..e6f669d3f 100644 --- a/gems/fact-mine/tests/profile_oracle.rs +++ b/gems/fact-mine/tests/profile_oracle.rs @@ -19,7 +19,7 @@ fn ruby_calculator_extracts_methods() -> Result<()> { let output = profile::extract(&document, Profile::Espalier); - // Methods — the normalized extractor detects the two functions + // Methods - the normalized extractor detects the two functions assert!( !output.methods.is_empty(), "should have at least one method" @@ -31,6 +31,11 @@ fn ruby_calculator_extracts_methods() -> Result<()> { .with_context(|| "missing 'add' method")?; assert_eq!(add_method.owner, "Calculator"); assert_eq!(add_method.kind, "instance"); + assert!(add_method.raw_source.contains("def add")); + assert_eq!( + add_method.normalized_source, + add_method.raw_source.split_whitespace().collect::>().join(" ") + ); assert!( !add_method.signature.is_empty() || true, "signature optional without Sorbet sigs" @@ -142,6 +147,7 @@ fn nil_kill_profile_produces_same_core_structure() -> Result<()> { #[test] fn nil_kill_all_profile_examples_extract_successfully() -> Result<()> { + let mut method_count = 0; for entry in fs::read_dir(examples_dir())? { let entry = entry?; let fixture = entry.path(); @@ -161,13 +167,19 @@ fn nil_kill_all_profile_examples_extract_successfully() -> Result<()> { let document = syntax::parse_file(fixture.clone(), lang) .with_context(|| format!("parse {}", fixture.display()))?; let output = profile::extract(&document, Profile::NilKill); - // Ensure it doesn't crash and returns a valid ProfileOutput - assert!(output.methods.len() >= 0); + method_count += output.methods.len(); + for method in output.methods { + assert!(!method.raw_source.is_empty(), "empty source for {}", method.name); + assert_eq!( + method.normalized_source, + method.raw_source.split_whitespace().collect::>().join(" ") + ); + } } + assert!(method_count > 0, "profile examples should contain functions"); Ok(()) } - #[test] fn state_writes_without_declarations_extract_as_fields() -> Result<()> { use std::io::Write; diff --git a/gems/lineage/README.md b/gems/lineage/README.md index dce94a512..c820aa186 100644 --- a/gems/lineage/README.md +++ b/gems/lineage/README.md @@ -89,6 +89,29 @@ cargo run --manifest-path gems/lineage/Cargo.toml -- ui \ --port 8080 ``` +### Focused architecture view + +Generate Espalier's structured graph, ingest it transactionally, and serve the +focused owner/function/state view: + +```bash +FACT_MINE_RUST_BINARY="$PWD/gems/fact-mine/target/release/fact-mine-rust" \ + ruby -Igems/espalier/lib gems/espalier/exe/espalier \ + --format architecture \ + --output tmp/espalier-architecture.json \ + gems/espalier/lib gems/lineage/src + +cargo run --manifest-path gems/lineage/Cargo.toml -- ingest-architecture \ + --db lineage.db \ + --input tmp/espalier-architecture.json + +cargo run --manifest-path gems/lineage/Cargo.toml -- ui \ + --db lineage.db --repo . --port 8080 +``` + +Architecture actions then appear beside matched symbols in the source outline. +The graph APIs are also available under `/api/architecture`. + ## Outputs Lineage can output a SQLite evidence database, text or JSON risk @@ -122,6 +145,11 @@ Core tables include: - `test_exposure_events` - `sarif_artifacts` - `sarif_findings` +- `architecture_artifacts` +- `architecture_nodes` +- `architecture_edges` +- `architecture_edge_spans` +- `architecture_pressure` Inspect the unit-level signal: @@ -144,12 +172,16 @@ from the same source and commit. | Source | Command | Current formats | | --- | --- | --- | | Git history | `build` | local Git repository | -| Coverage | `ingest-coverage` | Codecov JSON, SimpleCov JSON, Cobertura XML, kcov Cobertura XML | +| Coverage | `ingest-coverage` | Codecov JSON, SimpleCov JSON, Cobertura XML, kcov Cobertura XML, SQL-COV JSON (`--format sqlcov`) | | Test exposure | `ingest-test-exposure` | Lineage `test-exposure` JSON | | Mutation testing | `ingest-mutants` | Ruby `mutant-facts/v1` | | Systems hazards | `ingest-hazards` | Zig, Go, Rust, C, C++, C# hazard providers | | Stack traces | `ingest` | Sentry-style event JSON | -| Static analysis and risk findings | `ingest-sarif` | SARIF 2.1.0 files from Decomplex, SlopCop, Boobytrap, Nil-Kill, Espalier, and third-party tools | +| Static analysis and risk findings | `ingest-sarif` | SARIF 2.1.0 files from Decomplex, SlopCop, Boobytrap, Nil-Kill, Espalier, SQL-COV, and third-party tools | + +Standalone `.sql` files are indexed as query logical units. A leading +`-- query-id:` comment supplies the stable unit name, allowing SQL-COV branch +coverage and SQL hazard SARIF to attach to the same source view. | One-line repository import | `gems/lineage/bin/lineage-import` | Git history, coverage discovery, hazards, bundled first-party SARIF, Go/Rust/Ruby/Zig lint SARIF, extra SARIF | ### SARIF Findings diff --git a/gems/lineage/docs/agents/sql.md b/gems/lineage/docs/agents/sql.md new file mode 100644 index 000000000..412b64c7a --- /dev/null +++ b/gems/lineage/docs/agents/sql.md @@ -0,0 +1,319 @@ +# SQL as a First-Class Analyzable Artifact + +## Status + +Proposed architecture. This document does not require moving Lineage's current +inline SQL immediately. New and materially changed queries should follow this +layout, and existing queries can migrate incrementally. + +## Decision + +Store production SQL in standalone `.sql` files, embed those files into Rust at +compile time, and test the same files directly against database fixtures. + +Rust's built-in `include_str!` is the appropriate default: + +```rust +const OWNER_INVENTORY_SQL: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/sql/queries/architecture/owner_inventory.sql" +)); +``` + +This keeps deployment simple—the binary still contains the query—while making +the SQL independently readable, executable, lintable, explainable, and +testable. Rust owns parameter binding and decoding; the `.sql` file owns the +relational program. + +Do not construct structural SQL with string concatenation. Dynamic identifiers +or optional clauses should use a small, reviewed query builder or a bounded set +of complete query files. Values remain bound parameters. + +## Proposed Layout + +```text +gems/lineage/sql/ + schema/ + 001_core.sql + 002_architecture.sql + queries/ + architecture/ + owner_inventory.sql + node_neighborhood.sql + state_access.sql + search.sql + coverage/ + hazards/ + history/ + fixtures/ + minimal.sql + null_semantics.sql + join_cardinality.sql + architecture.sql + tests/ + architecture/ + owner_inventory.case.json + state_access.case.json + hazards/ + rules.json + suppressions.json + plans/ + sqlite/ +``` + +Each query should begin with machine-readable comments: + +```sql +-- query-id: architecture.owner_inventory.v1 +-- dialect: sqlite +-- params: artifact_id:integer, owner_id:text +-- result: node_id:text!, kind:text!, score:real? +-- cardinality: many +-- invariants: one-row-per-analyzer-node +``` + +The metadata may later move to a sidecar manifest, but the query ID must remain +stable. Lineage events, hazards, plans, and tests should refer to the ID rather +than a Rust function name. + +## Independent Query Tests + +Use three layers. They catch different failures and should not be collapsed +into one Rust integration test. + +### 1. Engine-level SQL cases + +Run schema, fixture, and query files directly through the target database +engine. For SQLite, the basic runner can use `sqlite3` or a small Rust test +binary backed by `rusqlite`. The query text must be read from the `.sql` file, +not copied into the test. + +A case file supplies: + +- query ID and dialect; +- fixture files; +- named parameter values; +- expected rows, unordered rows, scalar, or expected error; +- expected column names and nullability; +- required transaction mode; +- optional query-plan assertions. + +Golden rows are appropriate for stable report queries. Property assertions are +better for large result sets: uniqueness, conservation of counts, endpoint +closure, monotonicity, or equivalence to a deliberately simple reference query. + +### 2. Rust contract tests + +Rust tests verify the boundary rather than SQL semantics: + +- every embedded query ID exists exactly once; +- parameter names/types match the binder; +- result columns match the decoder; +- nullable SQL columns map to `Option`; +- read queries do not mutate the database; +- replacement/import queries are atomic; +- the Rust wrapper executes the exact embedded file tested independently. + +Prefer row structs with named-column decoding for durable APIs. Positional +decoding is acceptable only for very small private queries and should still +have a column-contract test. + +### 3. Repository workflow tests + +Exercise query results through the public Lineage API and UI. These tests cover +scope, artifact freshness, logical-unit reconciliation, pagination, and HTML or +JSON presentation. They should not be the first place where NULL or JOIN +semantics are tested. + +## Fixture Matrix + +Every nontrivial query should be exercised against a small relational matrix: + +- empty database; +- one matching row; +- multiple matching rows; +- duplicate relationship evidence; +- NULL on each nullable input independently; +- unmatched left and right join rows; +- one-to-one, one-to-many, and many-to-many joins; +- stale and current artifact versions; +- moved/renamed logical units; +- transaction rollback after an injected failure; +- boundary values for timestamps, line numbers, scores, and limits. + +Fixtures should be composable and minimal. A test that needs one nullable join +should not load a production-sized dump. + +## SQL Hazard Analyzer + +The motivating hazard family is hidden control flow caused by three-valued +logic and join cardinality. The initial catalog should cover the cases described +in [An Ode to SQL](https://cuzzo.github.io/clear/blog/an-ode-to-sql/): + +1. Nullable expressions compared with `!=` or `<>` without an explicit NULL + policy. +2. `NOT IN` where the left expression or subquery/list may contain NULL. +3. `NOT BETWEEN` over nullable operands. +4. `NOT`, `ANY`, or `ALL` expressions whose UNKNOWN result can reject rows. +5. A `LEFT JOIN` followed by a null-rejecting `WHERE` predicate on the optional + side, unintentionally turning it into an inner join. +6. A subsequent join or filter that consumes NULLs introduced by an outer join. +7. Nullable join keys without an explicit equality/null policy. +8. Join cardinality that can multiply rows when downstream code assumes one + row per entity. +9. Aggregates whose NULL behavior changes the result, including `COUNT(column)` + versus `COUNT(*)` and nullable arithmetic. +10. Anti-joins expressed with nullable `NOT IN` where `NOT EXISTS` or an + explicit NULL-aware predicate would make the policy visible. + +The analyzer must not blindly declare these constructs incorrect. It should +emit: + +- the potentially nullable expression and how nullability was established; +- the hidden TRUE/FALSE/UNKNOWN branch; +- the join side and cardinality involved; +- a source span; +- confidence and unresolved schema facts; +- safe alternatives appropriate to the dialect, without rewriting + automatically. + +### Fact model + +FactMine should eventually emit language-neutral SQL facts: + +```text +query definitions and statement spans +tables, aliases, columns, and CTEs +schema-derived and expression-derived nullability +joins, join type, predicates, and key pairs +filters and predicate operators +subqueries and correlation +grouping and aggregates +projected columns and aliases +estimated relationship cardinality +parameters and host-language call sites +``` + +SQL embedded in Ruby, Rust, Go, Zig, configuration, migrations, or heredocs +should point back to both the SQL span and the host-language span. + +## Determining Whether a Hazard Is Adequately Tested + +Code coverage alone cannot show that UNKNOWN behavior was tested. The test +system needs semantic witnesses. + +For every reported hazard, derive a required witness matrix. For example, a +nullable `LEFT JOIN` filter generally requires: + +```text +matching right row satisfying predicate +matching right row failing predicate +no matching right row +matching right row with predicate column NULL +duplicate matching right rows when cardinality is not proven one-to-one +``` + +Test cases declare which fixture rows and expected outcomes they cover. The SQL +test runner records query ID, hazard ID, fixture witness IDs, and assertion +results. Lineage can then distinguish: + +- hazard not exercised; +- SQL statement executed but NULL witness absent; +- witness present but output not asserted; +- semantic witness asserted and passing; +- mutation proved the assertion detects the hazard. + +The strongest check is SQL mutation testing. Generate constrained mutations +such as: + +- `LEFT JOIN` ↔ `INNER JOIN`; +- move an optional-side predicate between `ON` and `WHERE`; +- `=` ↔ `<>`; +- `IN` ↔ `NOT IN`; +- remove `IS NULL`/`IS NOT NULL` branches; +- `COUNT(*)` ↔ `COUNT(column)`; +- remove `DISTINCT`; +- change a join key or drop one part of a composite key. + +A test is mutation-adequate for a hazard only when the relevant mutation is +killed. Surviving mutations are evidence of a weak assertion or an equivalent +query and must be reviewed, not automatically counted as failure. + +## Query Plan and Performance Tests + +Correct results are necessary but insufficient for persistent analytical +queries. Record normalized `EXPLAIN QUERY PLAN` output for critical SQLite +queries and assert semantic properties rather than unstable plan text: + +- expected index is available and selected for large endpoint lookups; +- no full scan of an unbounded event/edge table; +- correlated subqueries do not create an accidental per-row repository scan; +- sort and temporary B-tree use is intentional; +- result limits are applied after the correct semantic ordering. + +Keep small correctness fixtures separate from generated scale fixtures. Plan +tests need realistic row distributions because optimizers make decisions from +cardinality and statistics. + +## Cross-Tool Test Bed + +The same SQL corpus can exercise the sibling tools without blurring ownership: + +| Tool | Responsibility | +| --- | --- | +| FactMine | Parse SQL and emit normalized definitions, predicates, joins, nullability, aggregates, cardinality clues, and exact spans. | +| Decomplex | Score hidden control-flow/state pressure from ternary predicates, correlated subqueries, join meshes, aggregation stages, and procedural SQL. | +| Espalier | Project table/view/procedure dependencies, query-to-table effects, ownership boundaries, and architecture pressure. | +| SlopCop | Report actionable SQL hazards and policy violations from FactMine facts. | +| Boobytrap | Capture runtime query identity, observed parameter/null/cardinality shapes, latency, errors, and production witnesses without recording sensitive values. | +| Lineage | Join query identity to history, tests, hazards, runtime evidence, mutations, ownership, and artifact freshness; present review queues and source navigation. | + +The corpus should contain: + +- one minimal positive and negative example per hazard; +- dialect variants where semantics differ; +- schema plus seed data; +- expected FactMine facts; +- expected SlopCop findings; +- expected Decomplex components; +- expected Espalier dependency edges; +- SQL test cases and mutations; +- sanitized Boobytrap observation fixtures; +- Lineage import/API/UI expectations. + +Each example needs a stable corpus ID so results from every tool can be joined +without parsing messages. + +## Dialects + +Start with SQLite because Lineage already embeds it and can run fixtures without +external services. Keep the public fact schema dialect-neutral, but never assume +all SQL semantics are identical. + +Add PostgreSQL, MySQL, or other dialect suites only with their actual engines in +CI. Parser acceptance is not evidence of runtime semantic parity. Query metadata +must declare its dialect, required extensions, and minimum engine capabilities. + +## Migration Plan + +1. Move the new architecture queries into `gems/lineage/sql/queries` without + changing behavior. +2. Add query IDs, parameter/result contracts, and direct fixture tests. +3. Move other high-risk multi-join and aggregation queries incrementally. +4. Add the NULL/join/cardinality fixture matrix. +5. Add a FactMine SQL fact schema and oracle corpus. +6. Implement conservative hazard rules with explicit confidence. +7. Record semantic witnesses and connect them to Lineage test exposure. +8. Add constrained SQL mutation testing. +9. Expand to runtime observations and additional dialect engines. + +## Acceptance Criteria + +- Production Rust embeds the exact `.sql` files exercised by direct tests. +- Every migrated query has a stable ID and parameter/result contract. +- NULL, unmatched-row, and cardinality fixtures cover every relevant query. +- Critical queries have plan assertions and scale fixtures. +- SQL hazards include source spans, confidence, and required semantic witnesses. +- Test adequacy distinguishes execution coverage from asserted NULL/cardinality + behavior and mutation evidence. +- Cross-tool fixtures join by stable IDs rather than human-readable messages. diff --git a/gems/lineage/looker/lineage.lkml b/gems/lineage/looker/lineage.lkml new file mode 100644 index 000000000..c9155d75b --- /dev/null +++ b/gems/lineage/looker/lineage.lkml @@ -0,0 +1,10 @@ +explore: logical_units { + join: events { + relationship: one_to_many + sql_on: ${logical_units.id} = ${events.unit_id} ;; + } + join: test_exposure_events { + relationship: one_to_many + sql_on: ${logical_units.id} = ${test_exposure_events.unit_id} ;; + } +} diff --git a/gems/lineage/sql/README.md b/gems/lineage/sql/README.md new file mode 100644 index 000000000..9f84d6aa4 --- /dev/null +++ b/gems/lineage/sql/README.md @@ -0,0 +1,19 @@ +# Lineage SQL + +Production queries are stored as standalone files and embedded into Rust with +`include_str!`. Architecture queries live under `architecture/`; 42 core +storage statements live under `storage/`; and 29 dashboard/source read-model +queries live under `ui/runtime/`. + +Lineage prepares the storage and UI corpus against its real SQLite schema. +SQL-COV parses and analyzes the executable corpus independently, while the +focused architecture fixture also executes queries for statement and +expression coverage. Schema and PRAGMA scripts remain standalone but are not +counted as expression-coverable queries. + +Every file begins with a stable `query-id`. Tests prepare and execute these exact +files; Rust must not carry a second copy of the query. + +Dynamic identifier and variable-length list builders cannot be represented by +one static file. They should publish a small family of rendered `.sql` fixtures +covering each query shape instead. diff --git a/gems/lineage/sql/architecture/artifact_health.sql b/gems/lineage/sql/architecture/artifact_health.sql new file mode 100644 index 000000000..a719d61ea --- /dev/null +++ b/gems/lineage/sql/architecture/artifact_health.sql @@ -0,0 +1,4 @@ +-- query-id: architecture.artifact_health.v1 +SELECT analyzer, analyzer_version, schema_version, commit_hash, complete, generated_at +FROM architecture_artifacts +WHERE id = ?1; diff --git a/gems/lineage/sql/architecture/delete_snapshot.sql b/gems/lineage/sql/architecture/delete_snapshot.sql new file mode 100644 index 000000000..20c62f40f --- /dev/null +++ b/gems/lineage/sql/architecture/delete_snapshot.sql @@ -0,0 +1,4 @@ +-- query-id: architecture.delete_snapshot.v1 +-- params: analyzer:text, commit_hash:text +DELETE FROM architecture_artifacts +WHERE analyzer = ?1 AND commit_hash = ?2; diff --git a/gems/lineage/sql/architecture/insert_artifact.sql b/gems/lineage/sql/architecture/insert_artifact.sql new file mode 100644 index 000000000..1f191aeb0 --- /dev/null +++ b/gems/lineage/sql/architecture/insert_artifact.sql @@ -0,0 +1,4 @@ +-- query-id: architecture.insert_artifact.v1 +INSERT INTO architecture_artifacts + (analyzer, analyzer_version, schema_version, commit_hash, root, complete, generated_at, payload_json) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8); diff --git a/gems/lineage/sql/architecture/insert_edge.sql b/gems/lineage/sql/architecture/insert_edge.sql new file mode 100644 index 000000000..f723da81c --- /dev/null +++ b/gems/lineage/sql/architecture/insert_edge.sql @@ -0,0 +1,4 @@ +-- query-id: architecture.insert_edge.v1 +INSERT INTO architecture_edges + (artifact_id, edge_id, source_node_id, target_node_id, kind, conditional, weight, confidence, metadata_json) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9); diff --git a/gems/lineage/sql/architecture/insert_edge_span.sql b/gems/lineage/sql/architecture/insert_edge_span.sql new file mode 100644 index 000000000..5567a15f4 --- /dev/null +++ b/gems/lineage/sql/architecture/insert_edge_span.sql @@ -0,0 +1,4 @@ +-- query-id: architecture.insert_edge_span.v1 +INSERT INTO architecture_edge_spans + (artifact_id, edge_id, path, start_line, start_column, end_line, end_column) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7); diff --git a/gems/lineage/sql/architecture/insert_node.sql b/gems/lineage/sql/architecture/insert_node.sql new file mode 100644 index 000000000..359636a45 --- /dev/null +++ b/gems/lineage/sql/architecture/insert_node.sql @@ -0,0 +1,5 @@ +-- query-id: architecture.insert_node.v1 +INSERT INTO architecture_nodes + (artifact_id, analyzer_node_id, logical_unit_id, owner_node_id, kind, name, owner, + language, path, start_line, start_column, end_line, end_column, confidence, metadata_json) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15); diff --git a/gems/lineage/sql/architecture/insert_pressure.sql b/gems/lineage/sql/architecture/insert_pressure.sql new file mode 100644 index 000000000..a2dfad942 --- /dev/null +++ b/gems/lineage/sql/architecture/insert_pressure.sql @@ -0,0 +1,4 @@ +-- query-id: architecture.insert_pressure.v1 +INSERT INTO architecture_pressure + (artifact_id, node_id, score, band, collaboration, state, implementation, operational, explanation_json) +VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9); diff --git a/gems/lineage/sql/architecture/latest_artifact.sql b/gems/lineage/sql/architecture/latest_artifact.sql new file mode 100644 index 000000000..cd7ff3ddd --- /dev/null +++ b/gems/lineage/sql/architecture/latest_artifact.sql @@ -0,0 +1,5 @@ +-- query-id: architecture.latest_artifact.v1 +SELECT id +FROM architecture_artifacts +ORDER BY id DESC +LIMIT 1; diff --git a/gems/lineage/sql/architecture/load_edges.sql b/gems/lineage/sql/architecture/load_edges.sql new file mode 100644 index 000000000..52509f380 --- /dev/null +++ b/gems/lineage/sql/architecture/load_edges.sql @@ -0,0 +1,17 @@ +-- query-id: architecture.load_edges.v1 +SELECT e.edge_id, e.source_node_id, e.target_node_id, e.kind, e.conditional, e.weight, e.confidence, e.metadata_json, + COALESCE(( + SELECT json_group_array(json_object( + 'path', s.path, + 'start_line', s.start_line, + 'start_column', s.start_column, + 'end_line', s.end_line, + 'end_column', s.end_column + )) + FROM architecture_edge_spans s + WHERE s.artifact_id = e.artifact_id AND s.edge_id = e.edge_id + ), '[]') +FROM architecture_edges e +WHERE e.artifact_id = ?1 + AND (e.source_node_id = ?2 OR e.target_node_id = ?2) +ORDER BY e.kind, e.source_node_id, e.target_node_id; diff --git a/gems/lineage/sql/architecture/load_node.sql b/gems/lineage/sql/architecture/load_node.sql new file mode 100644 index 000000000..5199d0b6f --- /dev/null +++ b/gems/lineage/sql/architecture/load_node.sql @@ -0,0 +1,5 @@ +-- query-id: architecture.load_node.v1 +SELECT analyzer_node_id, logical_unit_id, owner_node_id, kind, name, owner, language, path, + start_line, start_column, end_line, end_column, confidence, metadata_json +FROM architecture_nodes +WHERE artifact_id = ?1 AND analyzer_node_id = ?2; diff --git a/gems/lineage/sql/architecture/owner_inventory.sql b/gems/lineage/sql/architecture/owner_inventory.sql new file mode 100644 index 000000000..6fe9bbe04 --- /dev/null +++ b/gems/lineage/sql/architecture/owner_inventory.sql @@ -0,0 +1,15 @@ +-- query-id: architecture.owner_inventory.v1 +SELECT n.analyzer_node_id, n.logical_unit_id, n.owner_node_id, n.kind, n.name, n.owner, n.language, n.path, + n.start_line, n.start_column, n.end_line, n.end_column, n.confidence, n.metadata_json, + p.score, p.band, p.explanation_json, + (SELECT COUNT(*) FROM architecture_edges e WHERE e.artifact_id = n.artifact_id AND e.target_node_id = n.analyzer_node_id) AS incoming, + (SELECT COUNT(*) FROM architecture_edges e WHERE e.artifact_id = n.artifact_id AND e.source_node_id = n.analyzer_node_id) AS outgoing, + (SELECT COUNT(*) FROM unit_hazards h WHERE h.unit_id = n.logical_unit_id AND h.is_active = 1) AS hazards, + (SELECT COUNT(*) FROM events e WHERE e.unit_id = n.logical_unit_id AND e.event_type = 'CHANGE') AS changes, + (SELECT COUNT(*) FROM events e WHERE e.unit_id = n.logical_unit_id AND e.event_type = 'FIX') AS fixes, + COALESCE(u.current_distinct_tests, 0), COALESCE(u.current_line_cov, 0), COALESCE(u.current_mutant_cov, 0) +FROM architecture_nodes n +LEFT JOIN architecture_pressure p ON p.artifact_id = n.artifact_id AND p.node_id = n.analyzer_node_id +LEFT JOIN logical_units u ON u.id = n.logical_unit_id +WHERE n.artifact_id = ?1 AND n.owner_node_id = ?2 +ORDER BY COALESCE(p.score, 0) DESC, n.kind, n.name; diff --git a/gems/lineage/sql/architecture/reconcile_logical_unit.sql b/gems/lineage/sql/architecture/reconcile_logical_unit.sql new file mode 100644 index 000000000..71fc6cd08 --- /dev/null +++ b/gems/lineage/sql/architecture/reconcile_logical_unit.sql @@ -0,0 +1,21 @@ +-- query-id: architecture.reconcile_logical_unit.v1 +-- result: logical_unit_id:text? +WITH latest_events AS ( + SELECT e.* + FROM events e + WHERE e.id = ( + SELECT x.id + FROM events x + WHERE x.unit_id = e.unit_id + ORDER BY x.timestamp DESC, x.id DESC + LIMIT 1 + ) +) +SELECT u.id +FROM logical_units u +LEFT JOIN latest_events e ON e.unit_id = u.id +WHERE COALESCE(e.path, u.original_path) = ?1 + AND (u.name = ?2 OR u.name LIKE ?3) + AND u.type IN (?4, ?5) +ORDER BY ABS(COALESCE(e.start_line, u.start_line, 1) - ?6), u.id +LIMIT 1; diff --git a/gems/lineage/sql/architecture/search.sql b/gems/lineage/sql/architecture/search.sql new file mode 100644 index 000000000..6e8142ed4 --- /dev/null +++ b/gems/lineage/sql/architecture/search.sql @@ -0,0 +1,8 @@ +-- query-id: architecture.search.v1 +SELECT analyzer_node_id, kind, name, owner, path, start_line, metadata_json +FROM architecture_nodes +WHERE artifact_id = ?1 + AND lower(name) LIKE ?2 + AND (?3 IS NULL OR owner_node_id = ?3) +ORDER BY CASE kind WHEN 'owner' THEN 0 WHEN 'function' THEN 1 ELSE 2 END, name +LIMIT 100; diff --git a/gems/lineage/sql/storage/apply_decayed_risk.sql b/gems/lineage/sql/storage/apply_decayed_risk.sql new file mode 100644 index 000000000..5231feb6f --- /dev/null +++ b/gems/lineage/sql/storage/apply_decayed_risk.sql @@ -0,0 +1,91 @@ +-- query-id: storage.apply_decayed_risk.v1 +WITH fix_commit_raw AS ( + SELECT commit_hash, + COUNT(DISTINCT CASE + WHEN NOT ( + path LIKE 'spec/%' + OR path LIKE 'test/%' + OR path LIKE 'tests/%' + OR path LIKE 'transpile-tests/%' + OR path LIKE 'tools/fuzz/%' + OR path LIKE '%/spec/%' + OR path LIKE '%/test/%' + OR path LIKE '%_spec.%' + OR path LIKE '%_test.%' + ) + THEN unit_id END) AS code_units, + COUNT(DISTINCT CASE + WHEN NOT ( + path LIKE 'spec/%' + OR path LIKE 'test/%' + OR path LIKE 'tests/%' + OR path LIKE 'transpile-tests/%' + OR path LIKE 'tools/fuzz/%' + OR path LIKE '%/spec/%' + OR path LIKE '%/test/%' + OR path LIKE '%_spec.%' + OR path LIKE '%_test.%' + ) + THEN path END) AS code_files, + COALESCE(SUM(CASE + WHEN NOT ( + path LIKE 'spec/%' + OR path LIKE 'test/%' + OR path LIKE 'tests/%' + OR path LIKE 'transpile-tests/%' + OR path LIKE 'tools/fuzz/%' + OR path LIKE '%/spec/%' + OR path LIKE '%/test/%' + OR path LIKE '%_spec.%' + OR path LIKE '%_test.%' + ) + THEN ABS(lines_added) + ABS(lines_removed) ELSE 0 END), 0) AS code_lines + FROM events + WHERE event_type = 'FIX' + AND semantic_change = 1 + GROUP BY commit_hash + ), + fix_commit_profiles AS ( + SELECT commit_hash, + CASE + WHEN code_units BETWEEN 1 AND 3 + AND code_files BETWEEN 1 AND 3 + AND code_lines <= 80 + THEN 1.0 + WHEN code_units BETWEEN 1 AND 8 + AND code_files BETWEEN 1 AND 5 + AND code_lines <= 200 + THEN 0.65 + WHEN code_units BETWEEN 1 AND 20 + AND code_files BETWEEN 1 AND 10 + AND code_lines <= 500 + THEN 0.30 + ELSE 0.10 + END AS target_factor + FROM fix_commit_raw + ) + SELECT e.unit_id, + e.event_type, + e.timestamp, + CASE WHEN e.event_type = 'FIX' + THEN COALESCE(fp.target_factor, 0.10) + ELSE 1.0 + END AS target_factor, + CASE + WHEN e.event_type = 'FIX' + AND COALESCE(fp.target_factor, 0.10) >= 0.65 + AND EXISTS ( + SELECT 1 + FROM test_exposure_events t + WHERE t.unit_id = e.unit_id + AND t.timestamp > e.timestamp + AND t.is_mutation_killed = 1 + LIMIT 1 + ) + THEN 0.25 + ELSE 1.0 + END AS mutation_hardening_factor + FROM events e + LEFT JOIN fix_commit_profiles fp ON fp.commit_hash = e.commit_hash + WHERE e.semantic_change = 1 + AND e.event_type IN ('FIX', 'CHANGE') diff --git a/gems/lineage/sql/storage/backfill_mutation_kind.sql b/gems/lineage/sql/storage/backfill_mutation_kind.sql new file mode 100644 index 000000000..d3bfae845 --- /dev/null +++ b/gems/lineage/sql/storage/backfill_mutation_kind.sql @@ -0,0 +1,12 @@ +-- query-id: storage.backfill_mutation_kind.v1 +UPDATE test_exposure_events + SET mutation_kind = CASE + WHEN lower(COALESCE(test_type, '') || ' ' || COALESCE(test_id, '')) LIKE '%invariant%' + OR lower(COALESCE(test_type, '') || ' ' || COALESCE(test_id, '')) LIKE '%contract%' + OR lower(COALESCE(test_type, '') || ' ' || COALESCE(test_id, '')) LIKE '%property%' + OR lower(COALESCE(test_type, '') || ' ' || COALESCE(test_id, '')) LIKE '%fuzz%' + THEN 'invariant' + ELSE 'stochastic' + END + WHERE is_mutation_verified = 1 + AND COALESCE(mutation_kind, '') = '' diff --git a/gems/lineage/sql/storage/configure_connection.sql b/gems/lineage/sql/storage/configure_connection.sql new file mode 100644 index 000000000..97d5547bc --- /dev/null +++ b/gems/lineage/sql/storage/configure_connection.sql @@ -0,0 +1,3 @@ +-- query-id: storage.configure_connection.v1 +PRAGMA foreign_keys = ON; + PRAGMA synchronous = NORMAL; diff --git a/gems/lineage/sql/storage/current_unit_spans.sql b/gems/lineage/sql/storage/current_unit_spans.sql new file mode 100644 index 000000000..2acb85c64 --- /dev/null +++ b/gems/lineage/sql/storage/current_unit_spans.sql @@ -0,0 +1,24 @@ +-- query-id: storage.current_unit_spans.v1 +WITH latest_events AS ( + SELECT * + FROM ( + SELECT e.*, + ROW_NUMBER() OVER ( + PARTITION BY e.unit_id + ORDER BY e.timestamp DESC, e.id DESC + ) AS rank + FROM events e + ) + WHERE rank = 1 + ), + current_units AS ( + SELECT u.id, + COALESCE(le.path, u.original_path) AS current_path, + COALESCE(le.start_line, 1) AS start_line, + COALESCE(le.end_line, le.start_line, 1) AS end_line + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + ) + SELECT id, current_path, start_line, end_line + FROM current_units + WHERE current_path <> '' diff --git a/gems/lineage/sql/storage/current_unit_spans_for_path.sql b/gems/lineage/sql/storage/current_unit_spans_for_path.sql new file mode 100644 index 000000000..53eafe7ff --- /dev/null +++ b/gems/lineage/sql/storage/current_unit_spans_for_path.sql @@ -0,0 +1,31 @@ +-- query-id: storage.current_unit_spans_for_path.v1 +WITH filtered_units AS ( + SELECT id FROM logical_units WHERE original_path = ?1 + UNION + SELECT unit_id AS id FROM events WHERE path = ?1 + ), + latest_events AS ( + SELECT * + FROM ( + SELECT e.*, + ROW_NUMBER() OVER ( + PARTITION BY e.unit_id + ORDER BY e.timestamp DESC, e.id DESC + ) AS rank + FROM events e + WHERE e.unit_id IN (SELECT id FROM filtered_units) + ) + WHERE rank = 1 + ), + current_units AS ( + SELECT u.id, + COALESCE(le.path, u.original_path) AS current_path, + COALESCE(le.start_line, 1) AS start_line, + COALESCE(le.end_line, le.start_line, 1) AS end_line + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + WHERE u.id IN (SELECT id FROM filtered_units) + ) + SELECT id, current_path, start_line, end_line + FROM current_units + WHERE current_path = ?1 diff --git a/gems/lineage/sql/storage/delete_coverage_for_commit.sql b/gems/lineage/sql/storage/delete_coverage_for_commit.sql new file mode 100644 index 000000000..732e61eeb --- /dev/null +++ b/gems/lineage/sql/storage/delete_coverage_for_commit.sql @@ -0,0 +1,4 @@ +-- query-id: storage.delete_coverage_for_commit.v1 +DELETE FROM quality_events + WHERE commit_hash = ?1 + AND metric_type IN ('LINE_COV', 'INTEGRATION_COV', 'MUTANT_COV', 'GATE_STATUS') diff --git a/gems/lineage/sql/storage/delete_test_exposure_for_commit_test.sql b/gems/lineage/sql/storage/delete_test_exposure_for_commit_test.sql new file mode 100644 index 000000000..cd389b5cb --- /dev/null +++ b/gems/lineage/sql/storage/delete_test_exposure_for_commit_test.sql @@ -0,0 +1,4 @@ +-- query-id: storage.delete_test_exposure_for_commit_test.v1 +SELECT DISTINCT unit_id + FROM test_exposure_events + WHERE commit_hash = ?1 AND test_type = ?2 AND test_id = ?3 diff --git a/gems/lineage/sql/storage/delete_test_exposure_for_commit_test_2.sql b/gems/lineage/sql/storage/delete_test_exposure_for_commit_test_2.sql new file mode 100644 index 000000000..348975f82 --- /dev/null +++ b/gems/lineage/sql/storage/delete_test_exposure_for_commit_test_2.sql @@ -0,0 +1,3 @@ +-- query-id: storage.delete_test_exposure_for_commit_test_2.v1 +DELETE FROM test_exposure_events + WHERE commit_hash = ?1 AND test_type = ?2 AND test_id = ?3 diff --git a/gems/lineage/sql/storage/ensure_natural_key_indexes.sql b/gems/lineage/sql/storage/ensure_natural_key_indexes.sql new file mode 100644 index 000000000..c576b30da --- /dev/null +++ b/gems/lineage/sql/storage/ensure_natural_key_indexes.sql @@ -0,0 +1,96 @@ +-- query-id: storage.ensure_natural_key_indexes.v1 +DELETE FROM coverage_line_events + WHERE id NOT IN ( + SELECT ( + SELECT c2.id + FROM coverage_line_events c2 + WHERE c2.commit_hash = c1.commit_hash + AND c2.path = c1.path + AND c2.line = c1.line + AND c2.source = c1.source + ORDER BY c2.hits DESC, c2.timestamp DESC, c2.id DESC + LIMIT 1 + ) + FROM coverage_line_events c1 + GROUP BY c1.commit_hash, c1.path, c1.line, c1.source + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_coverage_line_events_natural_key + ON coverage_line_events(commit_hash, path, line, source); + + DELETE FROM quality_events + WHERE id NOT IN ( + SELECT ( + SELECT q2.id + FROM quality_events q2 + WHERE q2.unit_id = q1.unit_id + AND q2.commit_hash = q1.commit_hash + AND q2.metric_type = q1.metric_type + ORDER BY q2.new_value DESC, q2.timestamp DESC, q2.id DESC + LIMIT 1 + ) + FROM quality_events q1 + GROUP BY q1.unit_id, q1.commit_hash, q1.metric_type + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_quality_events_natural_key + ON quality_events(unit_id, commit_hash, metric_type); + + DELETE FROM crash_events + WHERE id NOT IN ( + SELECT ( + SELECT e2.id + FROM crash_events e2 + WHERE e2.unit_id = e1.unit_id + AND e2.commit_hash = e1.commit_hash + AND e2.error_class = e1.error_class + AND e2.provider_id = e1.provider_id + AND e2.path = e1.path + AND e2.line = e1.line + AND e2.function = e1.function + ORDER BY e2.is_verified DESC, e2.timestamp DESC, e2.id DESC + LIMIT 1 + ) + FROM crash_events e1 + GROUP BY e1.unit_id, e1.commit_hash, e1.error_class, e1.provider_id, + e1.path, e1.line, e1.function + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_crash_events_natural_key + ON crash_events(unit_id, commit_hash, error_class, provider_id, path, line, function); + + DELETE FROM test_exposure_events + WHERE id NOT IN ( + SELECT ( + SELECT t2.id + FROM test_exposure_events t2 + WHERE t2.unit_id = t1.unit_id + AND t2.commit_hash = t1.commit_hash + AND t2.path = t1.path + AND COALESCE(t2.line, -1) = COALESCE(t1.line, -1) + AND COALESCE(t2.branch_id, '') = COALESCE(t1.branch_id, '') + AND t2.test_id = t1.test_id + AND t2.test_type = t1.test_type + ORDER BY t2.is_verified DESC, + t2.is_mutation_killed DESC, + t2.is_mutation_verified DESC, + CASE + WHEN lower(COALESCE(t2.mutation_kind, '')) IN ('invariant', 'contract') THEN 2 + WHEN COALESCE(t2.mutation_kind, '') <> '' THEN 1 + ELSE 0 + END DESC, + t2.timestamp DESC, + t2.id DESC + LIMIT 1 + ) + FROM test_exposure_events t1 + GROUP BY t1.unit_id, t1.commit_hash, t1.path, COALESCE(t1.line, -1), + COALESCE(t1.branch_id, ''), t1.test_id, t1.test_type + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_test_exposure_events_natural_key + ON test_exposure_events( + unit_id, + commit_hash, + path, + COALESCE(line, -1), + COALESCE(branch_id, ''), + test_id, + test_type + ); diff --git a/gems/lineage/sql/storage/existing_quality_event.sql b/gems/lineage/sql/storage/existing_quality_event.sql new file mode 100644 index 000000000..5f866e389 --- /dev/null +++ b/gems/lineage/sql/storage/existing_quality_event.sql @@ -0,0 +1,6 @@ +-- query-id: storage.existing_quality_event.v1 +SELECT id, new_value + FROM quality_events + WHERE unit_id = ?1 AND commit_hash = ?2 AND metric_type = ?3 + ORDER BY id DESC + LIMIT 1 diff --git a/gems/lineage/sql/storage/find_definitions.sql b/gems/lineage/sql/storage/find_definitions.sql new file mode 100644 index 000000000..ef1297346 --- /dev/null +++ b/gems/lineage/sql/storage/find_definitions.sql @@ -0,0 +1,22 @@ +-- query-id: storage.find_definitions.v1 +SELECT u.id, + COALESCE(( + SELECT latest.path + FROM events latest + WHERE latest.unit_id = u.id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ), u.original_path) AS path, + COALESCE(( + SELECT latest.start_line + FROM events latest + WHERE latest.unit_id = u.id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ), u.start_line) AS start_line + FROM logical_units u + WHERE u.name = ?1 + OR u.name LIKE '%.' || ?1 + OR u.name LIKE '%::' || ?1 + OR u.name LIKE '%#' || ?1 + LIMIT 100 diff --git a/gems/lineage/sql/storage/init_schema.sql b/gems/lineage/sql/storage/init_schema.sql new file mode 100644 index 000000000..f96becf57 --- /dev/null +++ b/gems/lineage/sql/storage/init_schema.sql @@ -0,0 +1,330 @@ +-- query-id: storage.init_schema.v1 +PRAGMA foreign_keys = ON; + PRAGMA synchronous = NORMAL; + + CREATE TABLE IF NOT EXISTS logical_units ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + original_path TEXT NOT NULL, + created_at INTEGER NOT NULL, + start_line INTEGER DEFAULT 1, + current_line_cov REAL DEFAULT 0.0, + current_integration_cov REAL DEFAULT 0.0, + current_mutant_cov REAL DEFAULT 0.0, + is_hard_gated INTEGER DEFAULT 0, + current_distinct_tests INTEGER DEFAULT 0, + current_test_types TEXT DEFAULT '', + current_mutant_verified_tests INTEGER DEFAULT 0, + current_mutant_killed_tests INTEGER DEFAULT 0, + last_test_exposure_at INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unit_id TEXT NOT NULL, + commit_hash TEXT NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ('CHANGE', 'MOVE', 'FIX')), + path TEXT NOT NULL, + name TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + semantic_change INTEGER NOT NULL CHECK (semantic_change IN (0, 1)), + lines_added INTEGER NOT NULL DEFAULT 0, + lines_removed INTEGER NOT NULL DEFAULT 0, + timestamp INTEGER NOT NULL, + FOREIGN KEY(unit_id) REFERENCES logical_units(id) + ); + + CREATE TABLE IF NOT EXISTS metadata ( + commit_hash TEXT PRIMARY KEY, + message TEXT NOT NULL, + sentry_id TEXT, + coverage_delta REAL, + timestamp INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS quality_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unit_id TEXT NOT NULL, + commit_hash TEXT NOT NULL, + timestamp INTEGER NOT NULL, + metric_type TEXT NOT NULL CHECK ( + metric_type IN ('LINE_COV', 'INTEGRATION_COV', 'MUTANT_COV', 'GATE_STATUS') + ), + old_value REAL, + new_value REAL NOT NULL, + FOREIGN KEY(unit_id) REFERENCES logical_units(id) + ); + + CREATE TABLE IF NOT EXISTS crash_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unit_id TEXT NOT NULL, + commit_hash TEXT NOT NULL, + timestamp INTEGER NOT NULL, + error_class TEXT NOT NULL, + provider_id TEXT NOT NULL, + is_verified INTEGER NOT NULL CHECK (is_verified IN (0, 1)), + path TEXT NOT NULL, + line INTEGER NOT NULL, + function TEXT NOT NULL, + FOREIGN KEY(unit_id) REFERENCES logical_units(id) + ); + + CREATE TABLE IF NOT EXISTS test_exposure_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unit_id TEXT NOT NULL, + commit_hash TEXT NOT NULL, + timestamp INTEGER NOT NULL, + path TEXT NOT NULL, + function TEXT, + line INTEGER, + branch_id TEXT, + test_id TEXT NOT NULL, + test_type TEXT NOT NULL, + mutation_status TEXT, + mutation_kind TEXT NOT NULL DEFAULT '', + is_mutation_verified INTEGER NOT NULL CHECK (is_mutation_verified IN (0, 1)), + is_mutation_killed INTEGER NOT NULL CHECK (is_mutation_killed IN (0, 1)), + is_verified INTEGER NOT NULL CHECK (is_verified IN (0, 1)), + payload_json TEXT NOT NULL, + FOREIGN KEY(unit_id) REFERENCES logical_units(id) + ); + + CREATE TABLE IF NOT EXISTS unit_hazards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + unit_id TEXT NOT NULL, + language TEXT NOT NULL, + hazard_type TEXT NOT NULL, + required_evidence TEXT NOT NULL, + path TEXT NOT NULL, + line INTEGER NOT NULL, + symbol TEXT, + source TEXT NOT NULL, + detected_at_hash TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), + payload_json TEXT NOT NULL, + FOREIGN KEY(unit_id) REFERENCES logical_units(id) + ); + + CREATE TABLE IF NOT EXISTS coverage_line_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + commit_hash TEXT NOT NULL, + timestamp INTEGER NOT NULL, + path TEXT NOT NULL, + line INTEGER NOT NULL, + hits INTEGER NOT NULL, + is_partial INTEGER NOT NULL DEFAULT 0, + coverage_percent REAL, + source TEXT NOT NULL DEFAULT 'coverage', + UNIQUE(commit_hash, path, line, source) + ); + + CREATE TABLE IF NOT EXISTS sarif_artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, + tool_name TEXT NOT NULL, + run_format TEXT NOT NULL, + artifact_path TEXT NOT NULL, + artifact_sha256 TEXT NOT NULL, + commit_hash TEXT NOT NULL, + timestamp INTEGER NOT NULL, + payload_json TEXT NOT NULL, + ingested_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + UNIQUE(source, commit_hash, artifact_path, artifact_sha256) + ); + + CREATE TABLE IF NOT EXISTS sarif_findings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artifact_id INTEGER NOT NULL, + finding_key TEXT NOT NULL, + source TEXT NOT NULL, + tool_name TEXT NOT NULL, + run_format TEXT NOT NULL, + commit_hash TEXT NOT NULL, + timestamp INTEGER NOT NULL, + rule_id TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + path TEXT NOT NULL, + start_line INTEGER NOT NULL, + start_column INTEGER, + end_line INTEGER, + end_column INTEGER, + category TEXT NOT NULL, + is_dark_arm INTEGER NOT NULL CHECK (is_dark_arm IN (0, 1)), + unit_id TEXT, + fingerprint TEXT NOT NULL, + properties_json TEXT NOT NULL, + raw_json TEXT NOT NULL, + FOREIGN KEY(artifact_id) REFERENCES sarif_artifacts(id) ON DELETE CASCADE, + FOREIGN KEY(unit_id) REFERENCES logical_units(id), + UNIQUE(source, commit_hash, finding_key) + ); + + CREATE TABLE IF NOT EXISTS ui_file_summaries ( + path TEXT PRIMARY KEY, + units INTEGER NOT NULL, + hazards INTEGER NOT NULL, + evidence_covered_hazards INTEGER NOT NULL, + covered_hazards INTEGER NOT NULL, + distinct_tests INTEGER NOT NULL, + mutant_killed_tests INTEGER NOT NULL, + tracked_lines INTEGER NOT NULL, + covered_lines INTEGER NOT NULL, + partial_lines INTEGER NOT NULL DEFAULT 0, + line_coverage REAL NOT NULL, + mutant_coverage REAL NOT NULL, + mutant_verified_covered_lines INTEGER NOT NULL, + mutant_killed_covered_lines INTEGER NOT NULL, + stochastic_mutant_verified_covered_lines INTEGER NOT NULL, + stochastic_mutant_killed_covered_lines INTEGER NOT NULL, + invariant_mutant_verified_covered_lines INTEGER NOT NULL, + invariant_mutant_killed_covered_lines INTEGER NOT NULL, + multi_type_covered_lines INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ui_warning_units ( + unit_id TEXT PRIMARY KEY, + current_path TEXT NOT NULL, + current_distinct_tests INTEGER NOT NULL, + current_mutant_verified_tests INTEGER NOT NULL, + last_test_exposure_at INTEGER NOT NULL, + last_mutant_run_at INTEGER NOT NULL, + changes_after_test_exposure INTEGER NOT NULL, + semantic_changes_after_mutant_run INTEGER NOT NULL, + verification_stale_seconds INTEGER NOT NULL, + reopened_count INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS ui_refresh_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS engine_state ( + commit_hash TEXT PRIMARY KEY, + state_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS architecture_artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + analyzer TEXT NOT NULL, + analyzer_version TEXT NOT NULL, + schema_version INTEGER NOT NULL, + commit_hash TEXT NOT NULL, + root TEXT NOT NULL, + complete INTEGER NOT NULL, + generated_at TEXT NOT NULL, + payload_json TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS architecture_nodes ( + artifact_id INTEGER NOT NULL, + analyzer_node_id TEXT NOT NULL, + logical_unit_id TEXT, + owner_node_id TEXT, + kind TEXT NOT NULL, + name TEXT NOT NULL, + owner TEXT, + language TEXT, + path TEXT, + start_line INTEGER NOT NULL, + start_column INTEGER NOT NULL, + end_line INTEGER NOT NULL, + end_column INTEGER NOT NULL, + confidence TEXT NOT NULL, + metadata_json TEXT NOT NULL, + PRIMARY KEY (artifact_id, analyzer_node_id), + FOREIGN KEY(artifact_id) REFERENCES architecture_artifacts(id) ON DELETE CASCADE, + FOREIGN KEY(logical_unit_id) REFERENCES logical_units(id) + ); + + CREATE TABLE IF NOT EXISTS architecture_edges ( + artifact_id INTEGER NOT NULL, + edge_id TEXT NOT NULL, + source_node_id TEXT NOT NULL, + target_node_id TEXT NOT NULL, + kind TEXT NOT NULL, + conditional INTEGER NOT NULL, + weight INTEGER NOT NULL, + confidence TEXT NOT NULL, + metadata_json TEXT NOT NULL, + PRIMARY KEY (artifact_id, edge_id), + FOREIGN KEY(artifact_id) REFERENCES architecture_artifacts(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS architecture_edge_spans ( + artifact_id INTEGER NOT NULL, + edge_id TEXT NOT NULL, + path TEXT NOT NULL, + start_line INTEGER NOT NULL, + start_column INTEGER NOT NULL, + end_line INTEGER NOT NULL, + end_column INTEGER NOT NULL, + FOREIGN KEY(artifact_id) REFERENCES architecture_artifacts(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS architecture_pressure ( + artifact_id INTEGER NOT NULL, + node_id TEXT NOT NULL, + score REAL NOT NULL, + band TEXT NOT NULL, + collaboration REAL NOT NULL, + state REAL NOT NULL, + implementation REAL NOT NULL, + operational REAL NOT NULL, + explanation_json TEXT NOT NULL, + PRIMARY KEY (artifact_id, node_id), + FOREIGN KEY(artifact_id) REFERENCES architecture_artifacts(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_events_unit_id ON events(unit_id); + CREATE INDEX IF NOT EXISTS idx_events_unit_latest + ON events(unit_id, timestamp DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_events_unit_type_semantic_time + ON events(unit_id, event_type, semantic_change, timestamp); + CREATE INDEX IF NOT EXISTS idx_events_commit_hash ON events(commit_hash); + CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); + CREATE INDEX IF NOT EXISTS idx_quality_events_unit_id ON quality_events(unit_id); + CREATE INDEX IF NOT EXISTS idx_quality_events_commit_hash ON quality_events(commit_hash); + CREATE INDEX IF NOT EXISTS idx_crash_events_unit_id ON crash_events(unit_id); + CREATE INDEX IF NOT EXISTS idx_crash_events_unit_path_line_time + ON crash_events(unit_id, path, line, timestamp); + CREATE INDEX IF NOT EXISTS idx_crash_events_commit_hash ON crash_events(commit_hash); + CREATE INDEX IF NOT EXISTS idx_test_exposure_events_unit_id ON test_exposure_events(unit_id); + CREATE INDEX IF NOT EXISTS idx_test_exposure_events_unit_mutant_time + ON test_exposure_events(unit_id, is_mutation_verified, is_mutation_killed, timestamp); + CREATE INDEX IF NOT EXISTS idx_test_exposure_events_path_line_latest + ON test_exposure_events(path, line, branch_id, test_id, test_type, timestamp DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_test_exposure_events_commit_hash ON test_exposure_events(commit_hash); + CREATE INDEX IF NOT EXISTS idx_test_exposure_events_test_id ON test_exposure_events(test_id); + CREATE INDEX IF NOT EXISTS idx_test_exposure_events_type ON test_exposure_events(test_type); + CREATE INDEX IF NOT EXISTS idx_unit_hazards_unit_id ON unit_hazards(unit_id); + CREATE INDEX IF NOT EXISTS idx_unit_hazards_path_line ON unit_hazards(path, line); + CREATE INDEX IF NOT EXISTS idx_unit_hazards_type ON unit_hazards(hazard_type); + CREATE INDEX IF NOT EXISTS idx_unit_hazards_detected_at ON unit_hazards(detected_at_hash); + CREATE INDEX IF NOT EXISTS idx_coverage_line_events_path_line ON coverage_line_events(path, line); + CREATE INDEX IF NOT EXISTS idx_coverage_line_events_path_line_source_latest + ON coverage_line_events(path, line, source, timestamp DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_coverage_line_events_commit_hash ON coverage_line_events(commit_hash); + CREATE INDEX IF NOT EXISTS idx_sarif_artifacts_source_commit + ON sarif_artifacts(source, commit_hash); + CREATE INDEX IF NOT EXISTS idx_sarif_findings_path_line + ON sarif_findings(path, start_line); + CREATE INDEX IF NOT EXISTS idx_sarif_findings_source_commit + ON sarif_findings(source, commit_hash); + CREATE INDEX IF NOT EXISTS idx_sarif_findings_unit_id + ON sarif_findings(unit_id); + CREATE INDEX IF NOT EXISTS idx_sarif_findings_rule_id + ON sarif_findings(rule_id); + CREATE INDEX IF NOT EXISTS idx_ui_file_summaries_path ON ui_file_summaries(path); + CREATE INDEX IF NOT EXISTS idx_ui_warning_units_path ON ui_warning_units(current_path); + CREATE INDEX IF NOT EXISTS idx_events_path ON events(path); + CREATE INDEX IF NOT EXISTS idx_logical_units_original_path ON logical_units(original_path); + CREATE INDEX IF NOT EXISTS idx_architecture_artifacts_commit ON architecture_artifacts(commit_hash, id DESC); + CREATE INDEX IF NOT EXISTS idx_architecture_nodes_owner ON architecture_nodes(artifact_id, owner_node_id, kind); + CREATE INDEX IF NOT EXISTS idx_architecture_nodes_logical ON architecture_nodes(logical_unit_id); + CREATE INDEX IF NOT EXISTS idx_architecture_nodes_path ON architecture_nodes(artifact_id, path, start_line); + CREATE INDEX IF NOT EXISTS idx_architecture_edges_source ON architecture_edges(artifact_id, source_node_id, kind); + CREATE INDEX IF NOT EXISTS idx_architecture_edges_target ON architecture_edges(artifact_id, target_node_id, kind); diff --git a/gems/lineage/sql/storage/insert_crash_event.sql b/gems/lineage/sql/storage/insert_crash_event.sql new file mode 100644 index 000000000..ccc8ac1ec --- /dev/null +++ b/gems/lineage/sql/storage/insert_crash_event.sql @@ -0,0 +1,12 @@ +-- query-id: storage.insert_crash_event.v1 +SELECT id, timestamp, is_verified + FROM crash_events + WHERE unit_id = ?1 + AND commit_hash = ?2 + AND error_class = ?3 + AND provider_id = ?4 + AND path = ?5 + AND line = ?6 + AND function = ?7 + ORDER BY id DESC + LIMIT 1 diff --git a/gems/lineage/sql/storage/insert_crash_event_2.sql b/gems/lineage/sql/storage/insert_crash_event_2.sql new file mode 100644 index 000000000..bef0dbc47 --- /dev/null +++ b/gems/lineage/sql/storage/insert_crash_event_2.sql @@ -0,0 +1,5 @@ +-- query-id: storage.insert_crash_event_2.v1 +INSERT INTO crash_events + (unit_id, commit_hash, timestamp, error_class, provider_id, + is_verified, path, line, function) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) diff --git a/gems/lineage/sql/storage/insert_event.sql b/gems/lineage/sql/storage/insert_event.sql new file mode 100644 index 000000000..33a0ee2fd --- /dev/null +++ b/gems/lineage/sql/storage/insert_event.sql @@ -0,0 +1,5 @@ +-- query-id: storage.insert_event.v1 +INSERT INTO events + (unit_id, commit_hash, event_type, path, name, start_line, end_line, + semantic_change, lines_added, lines_removed, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) diff --git a/gems/lineage/sql/storage/insert_hazard_event.sql b/gems/lineage/sql/storage/insert_hazard_event.sql new file mode 100644 index 000000000..69cbc7311 --- /dev/null +++ b/gems/lineage/sql/storage/insert_hazard_event.sql @@ -0,0 +1,5 @@ +-- query-id: storage.insert_hazard_event.v1 +INSERT INTO unit_hazards + (unit_id, language, hazard_type, required_evidence, path, line, + symbol, source, detected_at_hash, is_active, payload_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) diff --git a/gems/lineage/sql/storage/insert_metadata.sql b/gems/lineage/sql/storage/insert_metadata.sql new file mode 100644 index 000000000..abb9ffa3e --- /dev/null +++ b/gems/lineage/sql/storage/insert_metadata.sql @@ -0,0 +1,3 @@ +-- query-id: storage.insert_metadata.v1 +INSERT OR IGNORE INTO metadata (commit_hash, message, timestamp) + VALUES (?1, ?2, ?3) diff --git a/gems/lineage/sql/storage/insert_sarif_artifact.sql b/gems/lineage/sql/storage/insert_sarif_artifact.sql new file mode 100644 index 000000000..05ae40cc3 --- /dev/null +++ b/gems/lineage/sql/storage/insert_sarif_artifact.sql @@ -0,0 +1,11 @@ +-- query-id: storage.insert_sarif_artifact.v1 +INSERT INTO sarif_artifacts + (source, tool_name, run_format, artifact_path, artifact_sha256, + commit_hash, timestamp, payload_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(source, commit_hash, artifact_path, artifact_sha256) DO UPDATE SET + tool_name = excluded.tool_name, + run_format = excluded.run_format, + timestamp = excluded.timestamp, + payload_json = excluded.payload_json, + ingested_at = strftime('%s', 'now') diff --git a/gems/lineage/sql/storage/insert_sarif_artifact_2.sql b/gems/lineage/sql/storage/insert_sarif_artifact_2.sql new file mode 100644 index 000000000..e89691faf --- /dev/null +++ b/gems/lineage/sql/storage/insert_sarif_artifact_2.sql @@ -0,0 +1,7 @@ +-- query-id: storage.insert_sarif_artifact_2.v1 +SELECT id + FROM sarif_artifacts + WHERE source = ?1 + AND commit_hash = ?2 + AND artifact_path = ?3 + AND artifact_sha256 = ?4 diff --git a/gems/lineage/sql/storage/insert_sarif_finding.sql b/gems/lineage/sql/storage/insert_sarif_finding.sql new file mode 100644 index 000000000..568a5cde2 --- /dev/null +++ b/gems/lineage/sql/storage/insert_sarif_finding.sql @@ -0,0 +1,8 @@ +-- query-id: storage.insert_sarif_finding.v1 +INSERT OR IGNORE INTO sarif_findings + (artifact_id, finding_key, source, tool_name, run_format, commit_hash, + timestamp, rule_id, level, message, path, start_line, start_column, + end_line, end_column, category, is_dark_arm, unit_id, fingerprint, + properties_json, raw_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, + ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21) diff --git a/gems/lineage/sql/storage/insert_test_exposure_event.sql b/gems/lineage/sql/storage/insert_test_exposure_event.sql new file mode 100644 index 000000000..875af434c --- /dev/null +++ b/gems/lineage/sql/storage/insert_test_exposure_event.sql @@ -0,0 +1,26 @@ +-- query-id: storage.insert_test_exposure_event.v1 +INSERT INTO test_exposure_events + (unit_id, commit_hash, timestamp, path, function, line, branch_id, + test_id, test_type, mutation_status, mutation_kind, is_mutation_verified, + is_mutation_killed, is_verified, payload_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, COALESCE(?11, ''), ?12, ?13, ?14, ?15) + ON CONFLICT DO UPDATE SET + timestamp = MAX(test_exposure_events.timestamp, excluded.timestamp), + function = COALESCE(excluded.function, test_exposure_events.function), + mutation_status = COALESCE(excluded.mutation_status, test_exposure_events.mutation_status), + mutation_kind = CASE + WHEN lower(COALESCE(test_exposure_events.mutation_kind, '')) IN ('invariant', 'contract') THEN test_exposure_events.mutation_kind + WHEN lower(COALESCE(excluded.mutation_kind, '')) IN ('invariant', 'contract') THEN excluded.mutation_kind + WHEN COALESCE(excluded.mutation_kind, '') <> '' THEN excluded.mutation_kind + ELSE test_exposure_events.mutation_kind + END, + is_mutation_verified = MAX(test_exposure_events.is_mutation_verified, excluded.is_mutation_verified), + is_mutation_killed = MAX(test_exposure_events.is_mutation_killed, excluded.is_mutation_killed), + is_verified = MAX(test_exposure_events.is_verified, excluded.is_verified), + payload_json = excluded.payload_json + WHERE excluded.timestamp > test_exposure_events.timestamp + OR excluded.is_mutation_verified > test_exposure_events.is_mutation_verified + OR excluded.is_mutation_killed > test_exposure_events.is_mutation_killed + OR excluded.is_verified > test_exposure_events.is_verified + OR COALESCE(excluded.mutation_kind, '') <> COALESCE(test_exposure_events.mutation_kind, '') + OR excluded.payload_json <> test_exposure_events.payload_json diff --git a/gems/lineage/sql/storage/record_coverage_line_with_details.sql b/gems/lineage/sql/storage/record_coverage_line_with_details.sql new file mode 100644 index 000000000..0fa08e723 --- /dev/null +++ b/gems/lineage/sql/storage/record_coverage_line_with_details.sql @@ -0,0 +1,13 @@ +-- query-id: storage.record_coverage_line_with_details.v1 +INSERT INTO coverage_line_events + (commit_hash, timestamp, path, line, hits, is_partial, coverage_percent, source) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(commit_hash, path, line, source) DO UPDATE SET + timestamp = MAX(coverage_line_events.timestamp, excluded.timestamp), + hits = MAX(coverage_line_events.hits, excluded.hits), + is_partial = MAX(coverage_line_events.is_partial, excluded.is_partial), + coverage_percent = COALESCE(excluded.coverage_percent, coverage_line_events.coverage_percent) + WHERE excluded.timestamp > coverage_line_events.timestamp + OR excluded.hits > coverage_line_events.hits + OR excluded.is_partial > coverage_line_events.is_partial + OR COALESCE(excluded.coverage_percent, -1) <> COALESCE(coverage_line_events.coverage_percent, -1) diff --git a/gems/lineage/sql/storage/record_quality_metric.sql b/gems/lineage/sql/storage/record_quality_metric.sql new file mode 100644 index 000000000..e23355d5b --- /dev/null +++ b/gems/lineage/sql/storage/record_quality_metric.sql @@ -0,0 +1,4 @@ +-- query-id: storage.record_quality_metric.v1 +UPDATE quality_events + SET timestamp = ?2, new_value = ?3 + WHERE id = ?1 diff --git a/gems/lineage/sql/storage/record_quality_metric_2.sql b/gems/lineage/sql/storage/record_quality_metric_2.sql new file mode 100644 index 000000000..0b816b65f --- /dev/null +++ b/gems/lineage/sql/storage/record_quality_metric_2.sql @@ -0,0 +1,4 @@ +-- query-id: storage.record_quality_metric_2.v1 +INSERT INTO quality_events + (unit_id, commit_hash, timestamp, metric_type, old_value, new_value) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) diff --git a/gems/lineage/sql/storage/refresh_current_quality_metrics.sql b/gems/lineage/sql/storage/refresh_current_quality_metrics.sql new file mode 100644 index 000000000..707d30f24 --- /dev/null +++ b/gems/lineage/sql/storage/refresh_current_quality_metrics.sql @@ -0,0 +1,6 @@ +-- query-id: storage.refresh_current_quality_metrics.v1 +UPDATE logical_units + SET current_line_cov = 0.0, + current_integration_cov = 0.0, + current_mutant_cov = 0.0, + is_hard_gated = 0; diff --git a/gems/lineage/sql/storage/refresh_current_quality_metrics_2.sql b/gems/lineage/sql/storage/refresh_current_quality_metrics_2.sql new file mode 100644 index 000000000..4eb57b0a0 --- /dev/null +++ b/gems/lineage/sql/storage/refresh_current_quality_metrics_2.sql @@ -0,0 +1,10 @@ +-- query-id: storage.refresh_current_quality_metrics_2.v1 +UPDATE logical_units + SET {column} = COALESCE(( + SELECT q.new_value + FROM quality_events q + WHERE q.unit_id = logical_units.id + AND q.metric_type = ?1 + ORDER BY q.timestamp DESC, q.id DESC + LIMIT 1 + ), 0.0) diff --git a/gems/lineage/sql/storage/refresh_current_sarif_findings_view.sql b/gems/lineage/sql/storage/refresh_current_sarif_findings_view.sql new file mode 100644 index 000000000..2b1a2f89c --- /dev/null +++ b/gems/lineage/sql/storage/refresh_current_sarif_findings_view.sql @@ -0,0 +1,30 @@ +-- query-id: storage.refresh_current_sarif_findings_view.v1 +DROP VIEW IF EXISTS current_sarif_findings; + CREATE VIEW current_sarif_findings AS + WITH finding_snapshots AS ( + SELECT path, source, tool_name, commit_hash, + MAX(timestamp) AS timestamp, + MAX(id) AS id + FROM sarif_findings + GROUP BY path, source, tool_name, commit_hash + ), + ranked_snapshots AS ( + SELECT path, source, tool_name, commit_hash, + ROW_NUMBER() OVER ( + PARTITION BY path, source, tool_name + ORDER BY timestamp DESC, id DESC + ) AS snapshot_rank + FROM finding_snapshots + ), + latest_snapshots AS ( + SELECT path, source, tool_name, commit_hash + FROM ranked_snapshots + WHERE snapshot_rank = 1 + ) + SELECT findings.* + FROM sarif_findings findings + JOIN latest_snapshots latest + ON latest.path = findings.path + AND latest.source = findings.source + AND latest.tool_name = findings.tool_name + AND latest.commit_hash = findings.commit_hash; diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary.sql b/gems/lineage/sql/storage/refresh_test_exposure_summary.sql new file mode 100644 index 000000000..2277b4832 --- /dev/null +++ b/gems/lineage/sql/storage/refresh_test_exposure_summary.sql @@ -0,0 +1,6 @@ +-- query-id: storage.refresh_test_exposure_summary.v1 +SELECT commit_hash, timestamp + FROM test_exposure_events + WHERE unit_id = ?1 + ORDER BY timestamp DESC, id DESC + LIMIT 1 diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_2.sql b/gems/lineage/sql/storage/refresh_test_exposure_summary_2.sql new file mode 100644 index 000000000..ccad523eb --- /dev/null +++ b/gems/lineage/sql/storage/refresh_test_exposure_summary_2.sql @@ -0,0 +1,4 @@ +-- query-id: storage.refresh_test_exposure_summary_2.v1 +SELECT COUNT(DISTINCT test_id) + FROM test_exposure_events + WHERE unit_id = ?1 AND commit_hash = ?2 diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_3.sql b/gems/lineage/sql/storage/refresh_test_exposure_summary_3.sql new file mode 100644 index 000000000..fdb725cf8 --- /dev/null +++ b/gems/lineage/sql/storage/refresh_test_exposure_summary_3.sql @@ -0,0 +1,4 @@ +-- query-id: storage.refresh_test_exposure_summary_3.v1 +SELECT COUNT(DISTINCT test_id) + FROM test_exposure_events + WHERE unit_id = ?1 AND commit_hash = ?2 AND is_mutation_verified = 1 diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_4.sql b/gems/lineage/sql/storage/refresh_test_exposure_summary_4.sql new file mode 100644 index 000000000..72342d27d --- /dev/null +++ b/gems/lineage/sql/storage/refresh_test_exposure_summary_4.sql @@ -0,0 +1,4 @@ +-- query-id: storage.refresh_test_exposure_summary_4.v1 +SELECT COUNT(DISTINCT test_id) + FROM test_exposure_events + WHERE unit_id = ?1 AND commit_hash = ?2 AND is_mutation_killed = 1 diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_5.sql b/gems/lineage/sql/storage/refresh_test_exposure_summary_5.sql new file mode 100644 index 000000000..e83cbacf2 --- /dev/null +++ b/gems/lineage/sql/storage/refresh_test_exposure_summary_5.sql @@ -0,0 +1,5 @@ +-- query-id: storage.refresh_test_exposure_summary_5.v1 +SELECT DISTINCT test_type + FROM test_exposure_events + WHERE unit_id = ?1 AND commit_hash = ?2 AND test_type <> '' + ORDER BY test_type diff --git a/gems/lineage/sql/storage/refresh_test_exposure_summary_6.sql b/gems/lineage/sql/storage/refresh_test_exposure_summary_6.sql new file mode 100644 index 000000000..1e53c945e --- /dev/null +++ b/gems/lineage/sql/storage/refresh_test_exposure_summary_6.sql @@ -0,0 +1,8 @@ +-- query-id: storage.refresh_test_exposure_summary_6.v1 +UPDATE logical_units + SET current_distinct_tests = ?2, + current_test_types = ?3, + current_mutant_verified_tests = ?4, + current_mutant_killed_tests = ?5, + last_test_exposure_at = ?6 + WHERE id = ?1 diff --git a/gems/lineage/sql/storage/refresh_ui_summaries.sql b/gems/lineage/sql/storage/refresh_ui_summaries.sql new file mode 100644 index 000000000..38fd54924 --- /dev/null +++ b/gems/lineage/sql/storage/refresh_ui_summaries.sql @@ -0,0 +1,407 @@ +-- query-id: storage.refresh_ui_summaries.v1 +DELETE FROM ui_file_summaries; + DELETE FROM ui_warning_units; + + WITH latest_events AS ( + SELECT unit_id, path + FROM ( + SELECT unit_id, path, + ROW_NUMBER() OVER ( + PARTITION BY unit_id + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM events + ) + WHERE rank = 1 + ), + current_units AS ( + SELECT u.id, + COALESCE(le.path, u.original_path) AS current_path, + u.current_line_cov, + u.current_mutant_cov, + u.current_distinct_tests, + u.current_mutant_killed_tests + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + ), + unit_file AS ( + SELECT current_path AS path, + COUNT(DISTINCT id) AS units, + COALESCE(SUM(current_distinct_tests), 0) AS distinct_tests, + COALESCE(SUM(current_mutant_killed_tests), 0) AS mutant_killed_tests, + COALESCE(AVG(current_line_cov), 0.0) AS fallback_line_coverage, + COALESCE(AVG(current_mutant_cov), 0.0) AS mutant_coverage + FROM current_units + WHERE current_path <> '' + GROUP BY current_path + ), + latest_source_lines AS ( + SELECT path, line, source, hits, is_partial, coverage_percent + FROM ( + SELECT path, line, source, hits, is_partial, coverage_percent, + ROW_NUMBER() OVER ( + PARTITION BY path, line, source + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM coverage_line_events + ) + WHERE rank = 1 + ), + latest_lines AS ( + SELECT path, line, MAX(hits) AS hits, + MAX(is_partial) AS is_partial, + CASE + WHEN MAX(is_partial) = 1 THEN MIN(CASE + WHEN is_partial = 1 + THEN COALESCE(coverage_percent, CASE WHEN hits > 0 THEN 100.0 ELSE 0.0 END) + END) + ELSE MAX(COALESCE(coverage_percent, CASE WHEN hits > 0 THEN 100.0 ELSE 0.0 END)) + END AS coverage_percent + FROM latest_source_lines + GROUP BY path, line + ), + line_file AS ( + SELECT path, + COUNT(*) AS tracked_lines, + SUM(CASE WHEN hits > 0 THEN 1 ELSE 0 END) AS covered_lines, + SUM(CASE WHEN hits > 0 AND is_partial = 1 THEN 1 ELSE 0 END) AS partial_lines, + AVG(coverage_percent) AS coverage_percent + FROM latest_lines + GROUP BY path + ), + ranked_exposure AS ( + SELECT path, line, branch_id, test_id, test_type, is_verified, + is_mutation_verified, is_mutation_killed, mutation_kind, + ROW_NUMBER() OVER ( + PARTITION BY path, line, COALESCE(branch_id, ''), test_id, test_type + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM test_exposure_events + WHERE line IS NOT NULL + ), + latest_exposure AS ( + SELECT * + FROM ranked_exposure + WHERE rank = 1 + ), + line_exposure AS ( + SELECT e.path, + e.line, + l.hits, + COUNT(DISTINCT CASE WHEN e.is_verified = 1 THEN e.test_type END) AS verified_test_types, + MAX(CASE WHEN e.is_verified = 1 AND e.is_mutation_verified = 1 THEN 1 ELSE 0 END) AS mutant_verified, + MAX(CASE WHEN e.is_verified = 1 AND e.is_mutation_killed = 1 THEN 1 ELSE 0 END) AS mutant_killed, + MAX(CASE + WHEN e.is_verified = 1 + AND e.is_mutation_verified = 1 + AND lower(COALESCE(e.mutation_kind, '')) = 'stochastic' + THEN 1 ELSE 0 + END) AS stochastic_mutant_verified, + MAX(CASE + WHEN e.is_verified = 1 + AND e.is_mutation_killed = 1 + AND lower(COALESCE(e.mutation_kind, '')) = 'stochastic' + THEN 1 ELSE 0 + END) AS stochastic_mutant_killed, + MAX(CASE + WHEN e.is_verified = 1 + AND e.is_mutation_killed = 1 + AND lower(COALESCE(e.mutation_kind, '')) IN ('invariant', 'contract') + THEN 1 ELSE 0 + END) AS invariant_mutant_killed, + MAX(CASE + WHEN e.is_verified = 1 + AND e.is_mutation_verified = 1 + AND lower(COALESCE(e.mutation_kind, '')) IN ('invariant', 'contract') + THEN 1 ELSE 0 + END) AS invariant_mutant_verified + FROM latest_exposure e + JOIN latest_lines l + ON l.path = e.path + AND l.line = e.line + AND l.hits > 0 + GROUP BY e.path, e.line + ), + exposure_file AS ( + SELECT path, + SUM(mutant_verified) AS mutant_verified_covered_lines, + SUM(mutant_killed) AS mutant_killed_covered_lines, + SUM(stochastic_mutant_verified) AS stochastic_mutant_verified_covered_lines, + SUM(stochastic_mutant_killed) AS stochastic_mutant_killed_covered_lines, + SUM(invariant_mutant_verified) AS invariant_mutant_verified_covered_lines, + SUM(invariant_mutant_killed) AS invariant_mutant_killed_covered_lines, + SUM(CASE WHEN verified_test_types >= 2 OR hits > 1 THEN 1 ELSE 0 END) AS multi_type_covered_lines + FROM line_exposure + GROUP BY path + ), + active_hazards AS ( + SELECT * + FROM unit_hazards + WHERE is_active = 1 + ), + hazard_ranked_exposure AS ( + SELECT t.unit_id, + t.path, + t.line, + t.branch_id, + t.test_id, + t.test_type, + t.is_verified, + t.is_mutation_killed, + t.mutation_kind, + ROW_NUMBER() OVER ( + PARTITION BY t.path, t.line, COALESCE(t.branch_id, ''), t.test_id, t.test_type + ORDER BY t.timestamp DESC, t.id DESC + ) AS rank + FROM test_exposure_events t + JOIN active_hazards h + ON h.unit_id = t.unit_id + AND h.path = t.path + AND h.line = t.line + WHERE t.line IS NOT NULL + ), + hazard_latest_exposure AS ( + SELECT * + FROM hazard_ranked_exposure + WHERE rank = 1 + ), + hazard_evidence AS ( + SELECT unit_id, + path, + line, + lower(test_type) AS test_type, + MAX(CASE WHEN is_verified = 1 THEN 1 ELSE 0 END) AS has_evidence, + MAX(CASE + WHEN is_verified = 1 + AND is_mutation_killed = 1 + AND lower(COALESCE(mutation_kind, '')) IN ('invariant', 'contract') + THEN 1 ELSE 0 + END) AS has_invariant_mutation + FROM hazard_latest_exposure + GROUP BY unit_id, path, line, lower(test_type) + ), + hazard_rows AS ( + SELECT h.id, + h.path, + CASE + WHEN MAX(CASE + WHEN (e.test_type = lower(h.required_evidence) + OR e.test_type LIKE '%' || lower(h.required_evidence) || '%') + AND e.has_evidence = 1 + THEN 1 ELSE 0 + END) = 1 + OR MAX(CASE + WHEN ls.hits > 0 + AND (lower(ls.source) = lower(h.required_evidence) + OR lower(ls.source) LIKE '%' || lower(h.required_evidence) || '%') + THEN 1 ELSE 0 + END) = 1 + THEN 1 ELSE 0 + END AS evidence_present, + CASE + WHEN MAX(CASE WHEN l.hits > 0 THEN 1 ELSE 0 END) = 1 + OR MAX(CASE + WHEN (e.test_type = lower(h.required_evidence) + OR e.test_type LIKE '%' || lower(h.required_evidence) || '%') + AND e.has_evidence = 1 + THEN 1 ELSE 0 + END) = 1 + OR MAX(CASE + WHEN ls.hits > 0 + AND (lower(ls.source) = lower(h.required_evidence) + OR lower(ls.source) LIKE '%' || lower(h.required_evidence) || '%') + THEN 1 ELSE 0 + END) = 1 + THEN 1 ELSE 0 + END AS verified + FROM active_hazards h + LEFT JOIN hazard_evidence e + ON e.unit_id = h.unit_id + AND e.path = h.path + AND e.line = h.line + LEFT JOIN latest_lines l + ON l.path = h.path + AND l.line = h.line + LEFT JOIN latest_source_lines ls + ON ls.path = h.path + AND ls.line = h.line + GROUP BY h.id, h.path + ), + hazard_file AS ( + SELECT path, + COUNT(*) AS hazards, + SUM(evidence_present) AS evidence_covered_hazards, + SUM(verified) AS covered_hazards + FROM hazard_rows + GROUP BY path + ), + paths AS ( + SELECT path FROM unit_file + UNION + SELECT path FROM line_file + UNION + SELECT path FROM exposure_file + UNION + SELECT path FROM hazard_file + ) + INSERT INTO ui_file_summaries ( + path, + units, + hazards, + evidence_covered_hazards, + covered_hazards, + distinct_tests, + mutant_killed_tests, + tracked_lines, + covered_lines, + partial_lines, + line_coverage, + mutant_coverage, + mutant_verified_covered_lines, + mutant_killed_covered_lines, + stochastic_mutant_verified_covered_lines, + stochastic_mutant_killed_covered_lines, + invariant_mutant_verified_covered_lines, + invariant_mutant_killed_covered_lines, + multi_type_covered_lines + ) + SELECT p.path, + COALESCE(uf.units, 0), + COALESCE(hf.hazards, 0), + COALESCE(hf.evidence_covered_hazards, 0), + COALESCE(hf.covered_hazards, 0), + COALESCE(uf.distinct_tests, 0), + COALESCE(uf.mutant_killed_tests, 0), + COALESCE(lf.tracked_lines, 0), + COALESCE(lf.covered_lines, 0), + COALESCE(lf.partial_lines, 0), + CASE + WHEN COALESCE(lf.tracked_lines, 0) > 0 + THEN COALESCE(lf.coverage_percent, 0.0) + ELSE COALESCE(uf.fallback_line_coverage, 0.0) + END, + COALESCE(uf.mutant_coverage, 0.0), + COALESCE(ef.mutant_verified_covered_lines, 0), + COALESCE(ef.mutant_killed_covered_lines, 0), + COALESCE(ef.stochastic_mutant_verified_covered_lines, 0), + COALESCE(ef.stochastic_mutant_killed_covered_lines, 0), + COALESCE(ef.invariant_mutant_verified_covered_lines, 0), + COALESCE(ef.invariant_mutant_killed_covered_lines, 0), + COALESCE(ef.multi_type_covered_lines, 0) + FROM paths p + LEFT JOIN unit_file uf ON uf.path = p.path + LEFT JOIN line_file lf ON lf.path = p.path + LEFT JOIN exposure_file ef ON ef.path = p.path + LEFT JOIN hazard_file hf ON hf.path = p.path + WHERE p.path <> ''; + + WITH latest_events AS ( + SELECT unit_id, path + FROM ( + SELECT unit_id, path, + ROW_NUMBER() OVER ( + PARTITION BY unit_id + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM events + ) + WHERE rank = 1 + ), + current_units AS ( + SELECT u.id, + COALESCE(le.path, u.original_path) AS current_path, + u.current_distinct_tests, + u.current_mutant_verified_tests, + u.last_test_exposure_at + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + ), + db_clock AS ( + SELECT COALESCE(MAX(timestamp), 0) AS observed_at + FROM ( + SELECT timestamp FROM metadata + UNION ALL SELECT timestamp FROM events + UNION ALL SELECT timestamp FROM quality_events + UNION ALL SELECT timestamp FROM crash_events + UNION ALL SELECT timestamp FROM test_exposure_events + ) + ), + mutant_runs AS ( + SELECT unit_id, MAX(timestamp) AS last_mutant_run_at + FROM test_exposure_events + WHERE is_mutation_verified = 1 OR is_mutation_killed = 1 + GROUP BY unit_id + ), + event_counts AS ( + SELECT cu.id, + SUM(CASE + WHEN cu.last_test_exposure_at > 0 + AND e.semantic_change = 1 + AND e.event_type IN ('FIX', 'CHANGE') + AND e.timestamp > cu.last_test_exposure_at + THEN 1 ELSE 0 + END) AS changes_after_test_exposure, + SUM(CASE + WHEN COALESCE(m.last_mutant_run_at, 0) > 0 + AND e.semantic_change = 1 + AND e.event_type IN ('FIX', 'CHANGE') + AND e.timestamp > m.last_mutant_run_at + THEN 1 ELSE 0 + END) AS semantic_changes_after_mutant_run + FROM current_units cu + LEFT JOIN mutant_runs m ON m.unit_id = cu.id + LEFT JOIN events e ON e.unit_id = cu.id + GROUP BY cu.id + ), + reopened AS ( + SELECT c.unit_id, COUNT(DISTINCT c.id) AS reopened_count + FROM crash_events c + WHERE EXISTS ( + SELECT 1 + FROM events fix + WHERE fix.unit_id = c.unit_id + AND fix.event_type = 'FIX' + AND fix.semantic_change = 1 + AND fix.path = c.path + AND c.line BETWEEN fix.start_line AND fix.end_line + AND c.timestamp > fix.timestamp + ) + GROUP BY c.unit_id + ) + INSERT INTO ui_warning_units ( + unit_id, + current_path, + current_distinct_tests, + current_mutant_verified_tests, + last_test_exposure_at, + last_mutant_run_at, + changes_after_test_exposure, + semantic_changes_after_mutant_run, + verification_stale_seconds, + reopened_count + ) + SELECT cu.id, + cu.current_path, + cu.current_distinct_tests, + cu.current_mutant_verified_tests, + cu.last_test_exposure_at, + COALESCE(m.last_mutant_run_at, 0), + COALESCE(ec.changes_after_test_exposure, 0), + COALESCE(ec.semantic_changes_after_mutant_run, 0), + CASE + WHEN COALESCE(m.last_mutant_run_at, 0) > 0 + AND clock.observed_at > m.last_mutant_run_at + THEN clock.observed_at - m.last_mutant_run_at + ELSE 0 + END, + COALESCE(r.reopened_count, 0) + FROM current_units cu + LEFT JOIN mutant_runs m ON m.unit_id = cu.id + LEFT JOIN event_counts ec ON ec.id = cu.id + LEFT JOIN reopened r ON r.unit_id = cu.id + CROSS JOIN db_clock clock + WHERE cu.current_path <> ''; + + INSERT INTO ui_refresh_metadata (key, value) + VALUES ('refreshed_at', strftime('%s', 'now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value; diff --git a/gems/lineage/sql/storage/resolve_current_path.sql b/gems/lineage/sql/storage/resolve_current_path.sql new file mode 100644 index 000000000..b372fd1a6 --- /dev/null +++ b/gems/lineage/sql/storage/resolve_current_path.sql @@ -0,0 +1,19 @@ +-- query-id: storage.resolve_current_path.v2 +SELECT e.path AS current_path +FROM events e +WHERE e.path = ?1 + AND e.id = ( + SELECT latest.id + FROM events latest + WHERE latest.unit_id = e.unit_id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ) +UNION ALL +SELECT u.original_path AS current_path +FROM logical_units u +WHERE u.original_path = ?1 + AND NOT EXISTS ( + SELECT 1 FROM events e WHERE e.unit_id = u.id + ) +LIMIT 1; diff --git a/gems/lineage/sql/storage/resolve_current_path_2.sql b/gems/lineage/sql/storage/resolve_current_path_2.sql new file mode 100644 index 000000000..d43d004c5 --- /dev/null +++ b/gems/lineage/sql/storage/resolve_current_path_2.sql @@ -0,0 +1,20 @@ +-- query-id: storage.resolve_current_path_2.v2 +SELECT e.path AS current_path +FROM events e +WHERE e.path LIKE ?1 + AND e.id = ( + SELECT latest.id + FROM events latest + WHERE latest.unit_id = e.unit_id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ) +UNION ALL +SELECT u.original_path AS current_path +FROM logical_units u +WHERE u.original_path LIKE ?1 + AND NOT EXISTS ( + SELECT 1 FROM events e WHERE e.unit_id = u.id + ) +ORDER BY current_path +LIMIT 2; diff --git a/gems/lineage/sql/storage/resolve_unit_id.sql b/gems/lineage/sql/storage/resolve_unit_id.sql new file mode 100644 index 000000000..a72da4523 --- /dev/null +++ b/gems/lineage/sql/storage/resolve_unit_id.sql @@ -0,0 +1,13 @@ +-- query-id: storage.resolve_unit_id.v1 +SELECT u.id + FROM logical_units u + WHERE u.name = ?2 + AND COALESCE(( + SELECT latest.path + FROM events latest + WHERE latest.unit_id = u.id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ), u.original_path) = ?1 + ORDER BY u.created_at DESC + LIMIT 1 diff --git a/gems/lineage/sql/storage/sarif_finding_counts_by_file.sql b/gems/lineage/sql/storage/sarif_finding_counts_by_file.sql new file mode 100644 index 000000000..4f6d0a44b --- /dev/null +++ b/gems/lineage/sql/storage/sarif_finding_counts_by_file.sql @@ -0,0 +1,4 @@ +-- query-id: storage.sarif_finding_counts_by_file.v1 +SELECT path, COUNT(*) AS findings + FROM current_sarif_findings + GROUP BY path diff --git a/gems/lineage/sql/storage/sarif_findings_for_path.sql b/gems/lineage/sql/storage/sarif_findings_for_path.sql new file mode 100644 index 000000000..09692a0b6 --- /dev/null +++ b/gems/lineage/sql/storage/sarif_findings_for_path.sql @@ -0,0 +1,8 @@ +-- query-id: storage.sarif_findings_for_path.v1 +SELECT artifact_id, finding_key, source, tool_name, run_format, commit_hash, + timestamp, rule_id, level, message, path, start_line, start_column, + end_line, end_column, category, is_dark_arm, unit_id, fingerprint, + properties_json, raw_json + FROM current_sarif_findings + WHERE path = ?1 + ORDER BY start_line, source, tool_name, rule_id, message diff --git a/gems/lineage/sql/storage/sarif_lifecycle_summary.sql b/gems/lineage/sql/storage/sarif_lifecycle_summary.sql new file mode 100644 index 000000000..7413cd5b7 --- /dev/null +++ b/gems/lineage/sql/storage/sarif_lifecycle_summary.sql @@ -0,0 +1,59 @@ +-- query-id: storage.sarif_lifecycle_summary.v1 +WITH commit_snapshots AS ( + SELECT path, source, tool_name, commit_hash, + MAX(timestamp) AS timestamp, MAX(id) AS id + FROM sarif_findings + GROUP BY path, source, tool_name, commit_hash + ), + ranked_snapshots AS ( + SELECT path, source, tool_name, commit_hash, + ROW_NUMBER() OVER ( + PARTITION BY path, source, tool_name + ORDER BY timestamp DESC, id DESC + ) AS snapshot_rank + FROM commit_snapshots + ), + current_findings AS ( + SELECT DISTINCT finding.path, finding.source, finding.tool_name, + finding.rule_id, finding.fingerprint + FROM sarif_findings finding + JOIN ranked_snapshots snapshot + ON snapshot.path = finding.path + AND snapshot.source = finding.source + AND snapshot.tool_name = finding.tool_name + AND snapshot.commit_hash = finding.commit_hash + AND snapshot.snapshot_rank = 1 + ), + previous_findings AS ( + SELECT DISTINCT finding.path, finding.source, finding.tool_name, + finding.rule_id, finding.fingerprint + FROM sarif_findings finding + JOIN ranked_snapshots snapshot + ON snapshot.path = finding.path + AND snapshot.source = finding.source + AND snapshot.tool_name = finding.tool_name + AND snapshot.commit_hash = finding.commit_hash + AND snapshot.snapshot_rank = 2 + ), + all_keys AS ( + SELECT path, source, tool_name, rule_id, fingerprint FROM current_findings + UNION + SELECT path, source, tool_name, rule_id, fingerprint FROM previous_findings + ) + SELECT + COALESCE(SUM(CASE WHEN current.fingerprint IS NOT NULL AND previous.fingerprint IS NULL THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN current.fingerprint IS NULL AND previous.fingerprint IS NOT NULL THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN current.fingerprint IS NOT NULL AND previous.fingerprint IS NOT NULL THEN 1 ELSE 0 END), 0) + FROM all_keys key + LEFT JOIN current_findings current + ON current.path = key.path + AND current.source = key.source + AND current.tool_name = key.tool_name + AND current.rule_id = key.rule_id + AND current.fingerprint = key.fingerprint + LEFT JOIN previous_findings previous + ON previous.path = key.path + AND previous.source = key.source + AND previous.tool_name = key.tool_name + AND previous.rule_id = key.rule_id + AND previous.fingerprint = key.fingerprint diff --git a/gems/lineage/sql/storage/unit_ids_for_current_path.sql b/gems/lineage/sql/storage/unit_ids_for_current_path.sql new file mode 100644 index 000000000..0ae7526d5 --- /dev/null +++ b/gems/lineage/sql/storage/unit_ids_for_current_path.sql @@ -0,0 +1,11 @@ +-- query-id: storage.unit_ids_for_current_path.v1 +SELECT u.id + FROM logical_units u + WHERE COALESCE(( + SELECT latest.path + FROM events latest + WHERE latest.unit_id = u.id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ), u.original_path) = ?1 + ORDER BY u.name, u.id diff --git a/gems/lineage/sql/storage/upsert_logical_unit.sql b/gems/lineage/sql/storage/upsert_logical_unit.sql new file mode 100644 index 000000000..486c268e2 --- /dev/null +++ b/gems/lineage/sql/storage/upsert_logical_unit.sql @@ -0,0 +1,4 @@ +-- query-id: storage.upsert_logical_unit.v1 +INSERT INTO logical_units (id, name, type, original_path, created_at, start_line) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(id) DO NOTHING diff --git a/gems/lineage/sql/ui/architecture_owner_by_name.sql b/gems/lineage/sql/ui/architecture_owner_by_name.sql new file mode 100644 index 000000000..49f7b168d --- /dev/null +++ b/gems/lineage/sql/ui/architecture_owner_by_name.sql @@ -0,0 +1,9 @@ +-- query-id: ui.architecture_owner_by_name.v1 +SELECT analyzer_node_id +FROM architecture_nodes +WHERE artifact_id = (SELECT id FROM architecture_artifacts ORDER BY id DESC LIMIT 1) + AND kind = 'owner' + AND path = ?1 + AND (name = ?2 OR name LIKE ?3) +ORDER BY CASE WHEN name = ?2 THEN 0 ELSE 1 END, start_line +LIMIT 1; diff --git a/gems/lineage/sql/ui/architecture_symbols_for_path.sql b/gems/lineage/sql/ui/architecture_symbols_for_path.sql new file mode 100644 index 000000000..e71c64c89 --- /dev/null +++ b/gems/lineage/sql/ui/architecture_symbols_for_path.sql @@ -0,0 +1,19 @@ +-- query-id: ui.architecture_symbols_for_path.v1 +SELECT n.analyzer_node_id, n.owner_node_id, n.kind, n.name, n.start_line, n.end_line, + COALESCE(p.score, 0), COALESCE(p.band, 'ordinary'), + COALESCE(( + SELECT group_concat(DISTINCT s.name) + FROM architecture_edges e + JOIN architecture_nodes s ON s.artifact_id = e.artifact_id AND s.analyzer_node_id = e.source_node_id + WHERE e.artifact_id = n.artifact_id AND e.target_node_id = n.analyzer_node_id AND e.kind = 'reads' + ), ''), + COALESCE(( + SELECT group_concat(DISTINCT s.name) + FROM architecture_edges e + JOIN architecture_nodes s ON s.artifact_id = e.artifact_id AND s.analyzer_node_id = e.target_node_id + WHERE e.artifact_id = n.artifact_id AND e.source_node_id = n.analyzer_node_id AND e.kind = 'writes' + ), '') +FROM architecture_nodes n +LEFT JOIN architecture_pressure p ON p.artifact_id = n.artifact_id AND p.node_id = n.analyzer_node_id +WHERE n.artifact_id = (SELECT id FROM architecture_artifacts ORDER BY id DESC LIMIT 1) + AND n.path = ?1; diff --git a/gems/lineage/sql/ui/runtime/analyzer_health.sql b/gems/lineage/sql/ui/runtime/analyzer_health.sql new file mode 100644 index 000000000..9b8918142 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/analyzer_health.sql @@ -0,0 +1,4 @@ +-- query-id: ui.runtime.analyzer_health.v1 +SELECT source, tool_name, COUNT(*) + FROM current_sarif_findings + GROUP BY source, tool_name diff --git a/gems/lineage/sql/ui/runtime/analyzer_health_2.sql b/gems/lineage/sql/ui/runtime/analyzer_health_2.sql new file mode 100644 index 000000000..017764bdc --- /dev/null +++ b/gems/lineage/sql/ui/runtime/analyzer_health_2.sql @@ -0,0 +1,5 @@ +-- query-id: ui.runtime.analyzer_health_2.v1 +SELECT source, tool_name, COUNT(*) + FROM current_sarif_findings + WHERE path = ?1 OR path LIKE ?2 + GROUP BY source, tool_name diff --git a/gems/lineage/sql/ui/runtime/analyzer_health_3.sql b/gems/lineage/sql/ui/runtime/analyzer_health_3.sql new file mode 100644 index 000000000..19a371ac2 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/analyzer_health_3.sql @@ -0,0 +1,12 @@ +-- query-id: ui.runtime.analyzer_health_3.v1 +SELECT source, tool_name, commit_hash, timestamp + FROM ( + SELECT source, tool_name, commit_hash, timestamp, + ROW_NUMBER() OVER ( + PARTITION BY source, tool_name + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM sarif_artifacts + ) + WHERE rank = 1 + ORDER BY lower(tool_name), lower(source) diff --git a/gems/lineage/sql/ui/runtime/apply_crash_history.sql b/gems/lineage/sql/ui/runtime/apply_crash_history.sql new file mode 100644 index 000000000..29912e6f6 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/apply_crash_history.sql @@ -0,0 +1,26 @@ +-- query-id: ui.runtime.apply_crash_history.v1 +WITH latest_events AS ( + SELECT e.* + FROM events e + WHERE e.id = ( + SELECT latest.id + FROM events latest + WHERE latest.unit_id = e.unit_id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ) + ) + SELECT COALESCE(le.start_line, 1) AS current_start, + COALESCE(le.end_line, le.start_line, 1) AS current_end, + c.path, + c.line, + c.commit_hash, + c.timestamp, + c.error_class, + c.provider_id, + c.function + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + JOIN crash_events c ON c.unit_id = u.id + WHERE COALESCE(le.path, u.original_path) = ?1 + ORDER BY c.timestamp DESC, c.id DESC diff --git a/gems/lineage/sql/ui/runtime/apply_hazards.sql b/gems/lineage/sql/ui/runtime/apply_hazards.sql new file mode 100644 index 000000000..e28e20091 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/apply_hazards.sql @@ -0,0 +1,94 @@ +-- query-id: ui.runtime.apply_hazards.v1 +WITH ranked_exposure AS ( + SELECT unit_id, path, line, test_type, is_verified, is_mutation_killed, mutation_kind, + ROW_NUMBER() OVER ( + PARTITION BY path, line, COALESCE(branch_id, ''), test_id, test_type + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM test_exposure_events + WHERE path = ?1 AND line IS NOT NULL + ), + latest_exposure AS ( + SELECT * + FROM ranked_exposure + WHERE rank = 1 + ), + latest_source_lines AS ( + SELECT path, line, source, hits + FROM ( + SELECT path, line, source, hits, + ROW_NUMBER() OVER ( + PARTITION BY path, line, source + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM coverage_line_events + WHERE path = ?1 + ) + WHERE rank = 1 + ), + latest_lines AS ( + SELECT path, line, MAX(hits) AS hits + FROM latest_source_lines + GROUP BY path, line + ), + evidence AS ( + SELECT unit_id, + path, + line, + lower(test_type) AS test_type, + MAX(CASE WHEN is_verified = 1 THEN 1 ELSE 0 END) AS has_evidence, + MAX(CASE + WHEN is_verified = 1 + AND is_mutation_killed = 1 + AND lower(COALESCE(mutation_kind, '')) IN ('invariant', 'contract') + THEN 1 ELSE 0 + END) AS has_invariant_mutation + FROM latest_exposure + GROUP BY unit_id, path, line, lower(test_type) + ) + SELECT h.line, h.hazard_type, h.required_evidence, h.source, + CASE + WHEN MAX(CASE + WHEN (e.test_type = lower(h.required_evidence) + OR e.test_type LIKE '%' || lower(h.required_evidence) || '%') + AND e.has_evidence = 1 + THEN 1 ELSE 0 + END) = 1 + OR MAX(CASE + WHEN ls.hits > 0 + AND (lower(ls.source) = lower(h.required_evidence) + OR lower(ls.source) LIKE '%' || lower(h.required_evidence) || '%') + THEN 1 ELSE 0 + END) = 1 + THEN 1 ELSE 0 + END AS evidence_present, + CASE + WHEN MAX(CASE WHEN l.hits > 0 THEN 1 ELSE 0 END) = 1 + OR MAX(CASE + WHEN (e.test_type = lower(h.required_evidence) + OR e.test_type LIKE '%' || lower(h.required_evidence) || '%') + AND e.has_evidence = 1 + THEN 1 ELSE 0 + END) = 1 + OR MAX(CASE + WHEN ls.hits > 0 + AND (lower(ls.source) = lower(h.required_evidence) + OR lower(ls.source) LIKE '%' || lower(h.required_evidence) || '%') + THEN 1 ELSE 0 + END) = 1 + THEN 1 ELSE 0 + END AS verified + FROM unit_hazards h + LEFT JOIN evidence e + ON e.unit_id = h.unit_id + AND e.path = h.path + AND e.line = h.line + LEFT JOIN latest_lines l + ON l.path = h.path + AND l.line = h.line + LEFT JOIN latest_source_lines ls + ON ls.path = h.path + AND ls.line = h.line + WHERE h.path = ?1 AND h.is_active = 1 + GROUP BY h.id, h.line, h.hazard_type, h.required_evidence, h.source + ORDER BY h.line, h.hazard_type diff --git a/gems/lineage/sql/ui/runtime/apply_line_coverage.sql b/gems/lineage/sql/ui/runtime/apply_line_coverage.sql new file mode 100644 index 000000000..e3b2f2dc5 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/apply_line_coverage.sql @@ -0,0 +1,19 @@ +-- query-id: ui.runtime.apply_line_coverage.v1 +WITH latest_source AS ( + SELECT line, source, hits, is_partial, + ROW_NUMBER() OVER ( + PARTITION BY line, source + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM coverage_line_events + WHERE path = ?1 + ), + latest AS ( + SELECT line, MAX(hits) AS hits, MAX(is_partial) AS is_partial + FROM latest_source + WHERE rank = 1 + GROUP BY line + ) + SELECT line, hits, COALESCE(is_partial, 0) + FROM latest + ORDER BY line diff --git a/gems/lineage/sql/ui/runtime/apply_semantic_churn.sql b/gems/lineage/sql/ui/runtime/apply_semantic_churn.sql new file mode 100644 index 000000000..fb1cc4343 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/apply_semantic_churn.sql @@ -0,0 +1,140 @@ +-- query-id: ui.runtime.apply_semantic_churn.v1 +WITH fix_commit_raw AS ( + SELECT commit_hash, + COUNT(DISTINCT CASE + WHEN NOT ( + path LIKE 'spec/%' + OR path LIKE 'test/%' + OR path LIKE 'tests/%' + OR path LIKE 'transpile-tests/%' + OR path LIKE 'tools/fuzz/%' + OR path LIKE '%/spec/%' + OR path LIKE '%/test/%' + OR path LIKE '%_spec.%' + OR path LIKE '%_test.%' + ) + THEN unit_id END) AS code_units, + COUNT(DISTINCT CASE + WHEN NOT ( + path LIKE 'spec/%' + OR path LIKE 'test/%' + OR path LIKE 'tests/%' + OR path LIKE 'transpile-tests/%' + OR path LIKE 'tools/fuzz/%' + OR path LIKE '%/spec/%' + OR path LIKE '%/test/%' + OR path LIKE '%_spec.%' + OR path LIKE '%_test.%' + ) + THEN path END) AS code_files, + COALESCE(SUM(CASE + WHEN NOT ( + path LIKE 'spec/%' + OR path LIKE 'test/%' + OR path LIKE 'tests/%' + OR path LIKE 'transpile-tests/%' + OR path LIKE 'tools/fuzz/%' + OR path LIKE '%/spec/%' + OR path LIKE '%/test/%' + OR path LIKE '%_spec.%' + OR path LIKE '%_test.%' + ) + THEN ABS(lines_added) + ABS(lines_removed) ELSE 0 END), 0) AS code_lines + FROM events + WHERE event_type = 'FIX' + AND semantic_change = 1 + GROUP BY commit_hash + ), + fix_commit_profiles AS ( + SELECT commit_hash, + CASE + WHEN code_units BETWEEN 1 AND 3 + AND code_files BETWEEN 1 AND 3 + AND code_lines <= 80 + THEN 1.0 + WHEN code_units BETWEEN 1 AND 8 + AND code_files BETWEEN 1 AND 5 + AND code_lines <= 200 + THEN 0.65 + WHEN code_units BETWEEN 1 AND 20 + AND code_files BETWEEN 1 AND 10 + AND code_lines <= 500 + THEN 0.30 + ELSE 0.10 + END AS target_factor + FROM fix_commit_raw + ), + latest_events AS ( + SELECT e.* + FROM events e + WHERE e.id = ( + SELECT latest.id + FROM events latest + WHERE latest.unit_id = e.unit_id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ) + ) + SELECT COALESCE(le.start_line, 1) AS current_start, + COALESCE(le.end_line, le.start_line, 1) AS current_end, + e.path, + e.start_line, + e.end_line, + e.event_type, + e.commit_hash, + e.timestamp, + e.name, + COALESCE(m.message, '') AS message, + CASE + WHEN ?2 = 1 THEN COALESCE(( + SELECT MIN(CASE + WHEN q.metric_type = 'MUTANT_COV' + AND q.old_value IS NOT NULL + AND q.new_value >= 70.0 + AND q.new_value - q.old_value >= 25.0 + THEN 0.15 + WHEN q.metric_type IN ('LINE_COV', 'INTEGRATION_COV') + AND q.old_value IS NOT NULL + AND q.new_value >= 80.0 + AND q.new_value - q.old_value >= 25.0 + THEN 0.35 + WHEN q.metric_type IN ('LINE_COV', 'INTEGRATION_COV', 'MUTANT_COV') + AND q.old_value IS NOT NULL + AND q.new_value - q.old_value >= 15.0 + THEN 0.60 + ELSE 1.0 + END) + FROM quality_events q + WHERE q.unit_id = e.unit_id + AND q.timestamp > e.timestamp + AND q.metric_type IN ('LINE_COV', 'INTEGRATION_COV', 'MUTANT_COV') + ), 1.0) + ELSE 1.0 + END AS protection_factor, + CASE WHEN e.event_type = 'FIX' + THEN COALESCE(fp.target_factor, 0.10) + ELSE 1.0 + END AS target_factor, + CASE + WHEN e.event_type = 'FIX' + AND COALESCE(fp.target_factor, 0.10) >= 0.65 + AND EXISTS ( + SELECT 1 + FROM test_exposure_events t + WHERE t.unit_id = e.unit_id + AND t.timestamp > e.timestamp + AND t.is_mutation_killed = 1 + LIMIT 1 + ) + THEN 0.25 + ELSE 1.0 + END AS mutation_hardening_factor + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + JOIN events e ON e.unit_id = u.id + LEFT JOIN metadata m ON m.commit_hash = e.commit_hash + LEFT JOIN fix_commit_profiles fp ON fp.commit_hash = e.commit_hash + WHERE COALESCE(le.path, u.original_path) = ?1 + AND e.semantic_change = 1 + AND e.event_type IN ('CHANGE', 'FIX') + ORDER BY e.timestamp DESC, e.id DESC diff --git a/gems/lineage/sql/ui/runtime/apply_test_exposure.sql b/gems/lineage/sql/ui/runtime/apply_test_exposure.sql new file mode 100644 index 000000000..59dd3e831 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/apply_test_exposure.sql @@ -0,0 +1,32 @@ +-- query-id: ui.runtime.apply_test_exposure.v1 +WITH ranked_exposure AS ( + SELECT path, line, branch_id, test_id, test_type, is_verified, + is_mutation_verified, is_mutation_killed, mutation_kind, + ROW_NUMBER() OVER ( + PARTITION BY path, line, COALESCE(branch_id, ''), test_id, test_type + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM test_exposure_events + WHERE path = ?1 AND line IS NOT NULL + ), + latest_exposure AS ( + SELECT * + FROM ranked_exposure + WHERE rank = 1 + ) + SELECT line, test_type, COUNT(DISTINCT test_id), + COUNT(DISTINCT CASE WHEN is_mutation_verified = 1 THEN test_id END), + COUNT(DISTINCT CASE WHEN is_mutation_killed = 1 THEN test_id END), + COUNT(DISTINCT CASE + WHEN is_mutation_verified = 1 + AND lower(COALESCE(mutation_kind, '')) = 'stochastic' + THEN test_id + END), + COUNT(DISTINCT CASE + WHEN is_mutation_verified = 1 + AND lower(COALESCE(mutation_kind, '')) IN ('invariant', 'contract') + THEN test_id + END) + FROM latest_exposure + WHERE is_verified = 1 + GROUP BY line, test_type diff --git a/gems/lineage/sql/ui/runtime/apply_unit_quality.sql b/gems/lineage/sql/ui/runtime/apply_unit_quality.sql new file mode 100644 index 000000000..c48fdde63 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/apply_unit_quality.sql @@ -0,0 +1,20 @@ +-- query-id: ui.runtime.apply_unit_quality.v1 +WITH latest_events AS ( + SELECT e.* + FROM events e + WHERE e.id = ( + SELECT latest.id + FROM events latest + WHERE latest.unit_id = e.unit_id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ) + ) + SELECT COALESCE(le.start_line, 1), + COALESCE(le.end_line, le.start_line, 1), + u.current_line_cov, + u.current_mutant_cov, + u.current_test_types + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + WHERE COALESCE(le.path, u.original_path) = ?1 diff --git a/gems/lineage/sql/ui/runtime/dashboard_coverage_line_counts.sql b/gems/lineage/sql/ui/runtime/dashboard_coverage_line_counts.sql new file mode 100644 index 000000000..fef7e2cb2 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/dashboard_coverage_line_counts.sql @@ -0,0 +1,20 @@ +-- query-id: ui.runtime.dashboard_coverage_line_counts.v1 +WITH latest_source_lines AS ( + SELECT path, line, hits + FROM ( + SELECT path, line, source, hits, + ROW_NUMBER() OVER ( + PARTITION BY path, line, source + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM coverage_line_events + ) + WHERE rank = 1 + ), + latest_lines AS ( + SELECT path, line, MAX(hits) AS hits + FROM latest_source_lines + GROUP BY path, line + ) + SELECT path, hits + FROM latest_lines diff --git a/gems/lineage/sql/ui/runtime/dashboard_hazard_counts.sql b/gems/lineage/sql/ui/runtime/dashboard_hazard_counts.sql new file mode 100644 index 000000000..e31fda2be --- /dev/null +++ b/gems/lineage/sql/ui/runtime/dashboard_hazard_counts.sql @@ -0,0 +1,106 @@ +-- query-id: ui.runtime.dashboard_hazard_counts.v1 +WITH active_hazards AS ( + SELECT * + FROM unit_hazards + WHERE is_active = 1 + ), + ranked_exposure AS ( + SELECT t.unit_id, + t.path, + t.line, + t.test_type, + t.is_verified, + t.is_mutation_killed, + t.mutation_kind, + ROW_NUMBER() OVER ( + PARTITION BY t.path, t.line, COALESCE(t.branch_id, ''), t.test_id, t.test_type + ORDER BY t.timestamp DESC, t.id DESC + ) AS rank + FROM test_exposure_events t + JOIN active_hazards h + ON h.unit_id = t.unit_id + AND h.path = t.path + AND h.line = t.line + WHERE t.line IS NOT NULL + ), + latest_exposure AS ( + SELECT * + FROM ranked_exposure + WHERE rank = 1 + ), + latest_source_lines AS ( + SELECT path, line, source, hits + FROM ( + SELECT path, line, source, hits, + ROW_NUMBER() OVER ( + PARTITION BY path, line, source + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM coverage_line_events + ) + WHERE rank = 1 + ), + latest_lines AS ( + SELECT path, line, MAX(hits) AS hits + FROM latest_source_lines + GROUP BY path, line + ), + evidence AS ( + SELECT unit_id, + path, + line, + lower(test_type) AS test_type, + MAX(CASE WHEN is_verified = 1 THEN 1 ELSE 0 END) AS has_evidence, + MAX(CASE + WHEN is_verified = 1 + AND is_mutation_killed = 1 + AND lower(COALESCE(mutation_kind, '')) IN ('invariant', 'contract') + THEN 1 ELSE 0 + END) AS has_invariant_mutation + FROM latest_exposure + GROUP BY unit_id, path, line, lower(test_type) + ) + SELECT h.path, + CASE + WHEN MAX(CASE + WHEN (e.test_type = lower(h.required_evidence) + OR e.test_type LIKE '%' || lower(h.required_evidence) || '%') + AND e.has_evidence = 1 + THEN 1 ELSE 0 + END) = 1 + OR MAX(CASE + WHEN ls.hits > 0 + AND (lower(ls.source) = lower(h.required_evidence) + OR lower(ls.source) LIKE '%' || lower(h.required_evidence) || '%') + THEN 1 ELSE 0 + END) = 1 + THEN 1 ELSE 0 + END AS evidence_present, + CASE + WHEN MAX(CASE WHEN l.hits > 0 THEN 1 ELSE 0 END) = 1 + OR MAX(CASE + WHEN (e.test_type = lower(h.required_evidence) + OR e.test_type LIKE '%' || lower(h.required_evidence) || '%') + AND e.has_evidence = 1 + THEN 1 ELSE 0 + END) = 1 + OR MAX(CASE + WHEN ls.hits > 0 + AND (lower(ls.source) = lower(h.required_evidence) + OR lower(ls.source) LIKE '%' || lower(h.required_evidence) || '%') + THEN 1 ELSE 0 + END) = 1 + THEN 1 ELSE 0 + END AS verified + FROM active_hazards h + LEFT JOIN evidence e + ON e.unit_id = h.unit_id + AND e.path = h.path + AND e.line = h.line + LEFT JOIN latest_lines l + ON l.path = h.path + AND l.line = h.line + LEFT JOIN latest_source_lines ls + ON ls.path = h.path + AND ls.line = h.line + GROUP BY h.id, h.path diff --git a/gems/lineage/sql/ui/runtime/dashboard_line_counts.sql b/gems/lineage/sql/ui/runtime/dashboard_line_counts.sql new file mode 100644 index 000000000..660ec2c37 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/dashboard_line_counts.sql @@ -0,0 +1,67 @@ +-- query-id: ui.runtime.dashboard_line_counts.v1 +WITH latest_source_lines AS ( + SELECT path, line, hits + FROM ( + SELECT path, line, source, hits, + ROW_NUMBER() OVER ( + PARTITION BY path, line, source + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM coverage_line_events + ) + WHERE rank = 1 + ), + latest_lines AS ( + SELECT path, line, MAX(hits) AS hits + FROM latest_source_lines + GROUP BY path, line + ), + ranked_exposure AS ( + SELECT path, line, branch_id, test_id, test_type, is_verified, + is_mutation_verified, is_mutation_killed, mutation_kind, + ROW_NUMBER() OVER ( + PARTITION BY path, line, COALESCE(branch_id, ''), test_id, test_type + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM test_exposure_events + WHERE line IS NOT NULL + ), + latest_exposure AS ( + SELECT * + FROM ranked_exposure + WHERE rank = 1 + ) + SELECT path, + line, + latest_lines.hits, + COUNT(DISTINCT CASE WHEN is_verified = 1 THEN test_type END) AS verified_test_types, + MAX(CASE WHEN is_verified = 1 AND is_mutation_verified = 1 THEN 1 ELSE 0 END) AS mutant_verified, + MAX(CASE WHEN is_verified = 1 AND is_mutation_killed = 1 THEN 1 ELSE 0 END) AS mutant_killed, + MAX(CASE + WHEN is_verified = 1 + AND is_mutation_verified = 1 + AND lower(COALESCE(mutation_kind, '')) = 'stochastic' + THEN 1 ELSE 0 + END) AS stochastic_mutant_verified, + MAX(CASE + WHEN is_verified = 1 + AND is_mutation_killed = 1 + AND lower(COALESCE(mutation_kind, '')) = 'stochastic' + THEN 1 ELSE 0 + END) AS stochastic_mutant_killed, + MAX(CASE + WHEN is_verified = 1 + AND is_mutation_killed = 1 + AND lower(COALESCE(mutation_kind, '')) IN ('invariant', 'contract') + THEN 1 ELSE 0 + END) AS invariant_mutant_killed, + MAX(CASE + WHEN is_verified = 1 + AND is_mutation_verified = 1 + AND lower(COALESCE(mutation_kind, '')) IN ('invariant', 'contract') + THEN 1 ELSE 0 + END) AS invariant_mutant_verified + FROM latest_exposure + JOIN latest_lines USING (path, line) + WHERE latest_lines.hits > 0 + GROUP BY path, line diff --git a/gems/lineage/sql/ui/runtime/decay_bounds.sql b/gems/lineage/sql/ui/runtime/decay_bounds.sql new file mode 100644 index 000000000..bec3a4abb --- /dev/null +++ b/gems/lineage/sql/ui/runtime/decay_bounds.sql @@ -0,0 +1,11 @@ +-- query-id: ui.runtime.decay_bounds.v1 +SELECT COALESCE(MIN(timestamp), 0), COALESCE(MAX(timestamp), 0) + FROM ( + SELECT timestamp + FROM events + WHERE semantic_change = 1 + AND event_type IN ('CHANGE', 'FIX') + UNION ALL + SELECT timestamp + FROM crash_events + ) diff --git a/gems/lineage/sql/ui/runtime/file_index_with_scope.sql b/gems/lineage/sql/ui/runtime/file_index_with_scope.sql new file mode 100644 index 000000000..db8933acf --- /dev/null +++ b/gems/lineage/sql/ui/runtime/file_index_with_scope.sql @@ -0,0 +1,36 @@ +-- query-id: ui.runtime.file_index_with_scope.v1 +WITH current_units AS ( + SELECT + u.id, + COALESCE(( + SELECT latest.path + FROM events latest + WHERE latest.unit_id = u.id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ), u.original_path) AS current_path, + u.current_line_cov, + u.current_mutant_cov, + u.current_distinct_tests, + u.current_mutant_killed_tests + FROM logical_units u + ), + hazard_counts AS ( + SELECT unit_id, COUNT(*) AS hazards + FROM unit_hazards + WHERE is_active = 1 + GROUP BY unit_id + ) + SELECT + cu.current_path, + COUNT(DISTINCT cu.id) AS units, + COALESCE(SUM(hc.hazards), 0) AS hazards, + COALESCE(SUM(cu.current_distinct_tests), 0) AS distinct_tests, + COALESCE(SUM(cu.current_mutant_killed_tests), 0) AS mutant_killed_tests, + COALESCE(AVG(cu.current_line_cov), 0.0) AS line_coverage, + COALESCE(AVG(cu.current_mutant_cov), 0.0) AS mutant_coverage + FROM current_units cu + LEFT JOIN hazard_counts hc ON hc.unit_id = cu.id + WHERE cu.current_path <> '' + GROUP BY cu.current_path + ORDER BY hazards DESC, mutant_killed_tests DESC, distinct_tests DESC, cu.current_path diff --git a/gems/lineage/sql/ui/runtime/file_versions.sql b/gems/lineage/sql/ui/runtime/file_versions.sql new file mode 100644 index 000000000..4b31b003d --- /dev/null +++ b/gems/lineage/sql/ui/runtime/file_versions.sql @@ -0,0 +1,34 @@ +-- query-id: ui.runtime.file_versions.v1 +WITH latest_events AS ( + SELECT e.unit_id, e.path + FROM events e + WHERE e.id = ( + SELECT latest.id + FROM events latest + WHERE latest.unit_id = e.unit_id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ) + ), + current_units AS ( + SELECT u.id + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + WHERE COALESCE(le.path, u.original_path) = ?1 + ), + union_query AS ( + SELECT e.commit_hash, e.timestamp, e.event_type, e.path, e.name, + e.start_line, e.end_line, e.semantic_change, e.id + FROM current_units cu + JOIN events e ON e.unit_id = cu.id + UNION + SELECT e.commit_hash, e.timestamp, e.event_type, e.path, e.name, + e.start_line, e.end_line, e.semantic_change, e.id + FROM events e + WHERE e.path = ?1 + ) + SELECT commit_hash, timestamp, event_type, path, name, + start_line, end_line, semantic_change + FROM union_query + ORDER BY timestamp DESC, id DESC + LIMIT 200 diff --git a/gems/lineage/sql/ui/runtime/fix_decay_bounds.sql b/gems/lineage/sql/ui/runtime/fix_decay_bounds.sql new file mode 100644 index 000000000..b174fe531 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/fix_decay_bounds.sql @@ -0,0 +1,5 @@ +-- query-id: ui.runtime.fix_decay_bounds.v1 +SELECT MIN(timestamp), MAX(timestamp) + FROM events + WHERE semantic_change = 1 + AND event_type = 'FIX' diff --git a/gems/lineage/sql/ui/runtime/has_multicommit_quality_history.sql b/gems/lineage/sql/ui/runtime/has_multicommit_quality_history.sql new file mode 100644 index 000000000..0b8dc7440 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/has_multicommit_quality_history.sql @@ -0,0 +1,4 @@ +-- query-id: ui.runtime.has_multicommit_quality_history.v1 +SELECT COUNT(DISTINCT commit_hash) + FROM quality_events + WHERE metric_type IN ('LINE_COV', 'INTEGRATION_COV', 'MUTANT_COV') diff --git a/gems/lineage/sql/ui/runtime/line_coverage_by_file.sql b/gems/lineage/sql/ui/runtime/line_coverage_by_file.sql new file mode 100644 index 000000000..0b7ea6083 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/line_coverage_by_file.sql @@ -0,0 +1,29 @@ +-- query-id: ui.runtime.line_coverage_by_file.v1 +WITH latest_source_lines AS ( + SELECT path, line, hits, is_partial, coverage_percent + FROM ( + SELECT path, line, source, hits, is_partial, coverage_percent, + ROW_NUMBER() OVER ( + PARTITION BY path, line, source + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM coverage_line_events + ) + WHERE rank = 1 + ), + latest_lines AS ( + SELECT path, line, MAX(hits) AS hits, + MAX(is_partial) AS is_partial, + CASE + WHEN MAX(is_partial) = 1 THEN MIN(CASE + WHEN is_partial = 1 + THEN COALESCE(coverage_percent, CASE WHEN hits > 0 THEN 100.0 ELSE 0.0 END) + END) + ELSE MAX(COALESCE(coverage_percent, CASE WHEN hits > 0 THEN 100.0 ELSE 0.0 END)) + END AS coverage_percent + FROM latest_source_lines + GROUP BY path, line + ) + SELECT path, hits, is_partial, coverage_percent + FROM latest_lines + ORDER BY path diff --git a/gems/lineage/sql/ui/runtime/persisted_source_symbols.sql b/gems/lineage/sql/ui/runtime/persisted_source_symbols.sql new file mode 100644 index 000000000..da284442a --- /dev/null +++ b/gems/lineage/sql/ui/runtime/persisted_source_symbols.sql @@ -0,0 +1,20 @@ +-- query-id: ui.runtime.persisted_source_symbols.v1 +WITH latest_events AS ( + SELECT e.* + FROM events e + WHERE e.id = ( + SELECT latest.id + FROM events latest + WHERE latest.unit_id = e.unit_id + ORDER BY latest.timestamp DESC, latest.id DESC + LIMIT 1 + ) + ) + SELECT u.type, + u.name, + COALESCE(le.start_line, 1) AS start_line, + COALESCE(le.end_line, le.start_line, 1) AS end_line + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + WHERE COALESCE(le.path, u.original_path) = ?1 + ORDER BY start_line, end_line, u.type, u.name diff --git a/gems/lineage/sql/ui/runtime/read_model_file_index_with_scope.sql b/gems/lineage/sql/ui/runtime/read_model_file_index_with_scope.sql new file mode 100644 index 000000000..aa3e7d6ef --- /dev/null +++ b/gems/lineage/sql/ui/runtime/read_model_file_index_with_scope.sql @@ -0,0 +1,22 @@ +-- query-id: ui.runtime.read_model_file_index_with_scope.v1 +SELECT path, + units, + hazards, + evidence_covered_hazards, + covered_hazards, + distinct_tests, + mutant_killed_tests, + tracked_lines, + covered_lines, + partial_lines, + line_coverage, + mutant_coverage, + mutant_verified_covered_lines, + mutant_killed_covered_lines, + stochastic_mutant_verified_covered_lines, + stochastic_mutant_killed_covered_lines, + invariant_mutant_verified_covered_lines, + invariant_mutant_killed_covered_lines, + multi_type_covered_lines + FROM ui_file_summaries + ORDER BY hazards DESC, mutant_killed_tests DESC, distinct_tests DESC, path diff --git a/gems/lineage/sql/ui/runtime/review_next_items.sql b/gems/lineage/sql/ui/runtime/review_next_items.sql new file mode 100644 index 000000000..b084ccf17 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/review_next_items.sql @@ -0,0 +1,23 @@ +-- query-id: ui.runtime.review_next_items.v1 +WITH grouped AS ( + SELECT finding.path, + MIN(finding.start_line) AS start_line, + COALESCE(unit.name, MIN(finding.rule_id)) AS title, + GROUP_CONCAT(DISTINCT finding.tool_name) AS tools, + COUNT(*) AS findings, + SUM(CASE WHEN lower(finding.level) IN ('error', 'warning') THEN 1 ELSE 0 END) AS warnings, + SUM(finding.is_dark_arm) AS dark_arms, + MIN(finding.rule_id || ': ' || finding.message) AS example, + COUNT(DISTINCT finding.tool_name) AS tool_count + FROM current_sarif_findings finding + LEFT JOIN logical_units unit ON unit.id = finding.unit_id + WHERE finding.path <> '' + AND (?1 = '' OR finding.path = ?1 OR finding.path LIKE ?2) + GROUP BY finding.path, + COALESCE(finding.unit_id, 'line:' || finding.start_line) + ) + SELECT path, start_line, title, tools, findings, warnings, dark_arms, example, tool_count, + warnings * 3.0 + dark_arms * 5.0 + findings + tool_count * 2.0 AS score + FROM grouped + ORDER BY score DESC, path, start_line + LIMIT 500 diff --git a/gems/lineage/sql/ui/runtime/sarif_dark_arm_counts_by_file.sql b/gems/lineage/sql/ui/runtime/sarif_dark_arm_counts_by_file.sql new file mode 100644 index 000000000..5da94b147 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/sarif_dark_arm_counts_by_file.sql @@ -0,0 +1,5 @@ +-- query-id: ui.runtime.sarif_dark_arm_counts_by_file.v1 +SELECT path, COUNT(*) AS findings + FROM current_sarif_findings + WHERE is_dark_arm = 1 + GROUP BY path diff --git a/gems/lineage/sql/ui/runtime/top_architecture_risks.sql b/gems/lineage/sql/ui/runtime/top_architecture_risks.sql new file mode 100644 index 000000000..1153c1748 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/top_architecture_risks.sql @@ -0,0 +1,6 @@ +-- query-id: ui.runtime.top_architecture_risks.v1 +SELECT path, start_line, rule_id, level, message, properties_json + FROM current_sarif_findings + WHERE lower(tool_name) = 'espalier' + OR lower(run_format) = 'espalier.manifest.sarif.v1' + OR lower(source) LIKE '%espalier%' diff --git a/gems/lineage/sql/ui/runtime/top_complexity_functions.sql b/gems/lineage/sql/ui/runtime/top_complexity_functions.sql new file mode 100644 index 000000000..da2616f13 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/top_complexity_functions.sql @@ -0,0 +1,14 @@ +-- query-id: ui.runtime.top_complexity_functions.v1 +WITH latest_espalier AS ( + SELECT commit_hash + FROM current_sarif_findings + WHERE rule_id = 'espalier.function' + OR (LOWER(tool_name) = 'espalier' AND rule_id LIKE '%.function') + ORDER BY timestamp DESC, id DESC + LIMIT 1 + ) + SELECT path, start_line, properties_json, message + FROM current_sarif_findings + WHERE (rule_id = 'espalier.function' + OR (LOWER(tool_name) = 'espalier' AND rule_id LIKE '%.function')) + AND commit_hash = (SELECT commit_hash FROM latest_espalier) diff --git a/gems/lineage/sql/ui/runtime/unit_signal_counts.sql b/gems/lineage/sql/ui/runtime/unit_signal_counts.sql new file mode 100644 index 000000000..c6abe94b0 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/unit_signal_counts.sql @@ -0,0 +1,24 @@ +-- query-id: ui.runtime.unit_signal_counts.v1 +SELECT unit_id, + SUM(CASE WHEN NOT ( + lower(category) = 'lint' + OR lower(source) LIKE '%lint%' + OR lower(tool_name) IN ('rubocop', 'clippy', 'zig ast check') + OR lower(rule_id) LIKE 'lint/%' + OR lower(rule_id) LIKE 'security/%' + OR lower(rule_id) LIKE 'clippy::%' + OR lower(rule_id) LIKE 'zig.ast-check%' + ) THEN 1 ELSE 0 END) AS sarif_findings, + SUM(CASE WHEN ( + lower(category) = 'lint' + OR lower(source) LIKE '%lint%' + OR lower(tool_name) IN ('rubocop', 'clippy', 'zig ast check') + OR lower(rule_id) LIKE 'lint/%' + OR lower(rule_id) LIKE 'security/%' + OR lower(rule_id) LIKE 'clippy::%' + OR lower(rule_id) LIKE 'zig.ast-check%' + ) THEN 1 ELSE 0 END) AS lint_findings, + SUM(CASE WHEN is_dark_arm = 1 THEN 1 ELSE 0 END) AS dark_arms + FROM current_sarif_findings + WHERE unit_id IS NOT NULL + GROUP BY unit_id diff --git a/gems/lineage/sql/ui/runtime/unit_signal_counts_2.sql b/gems/lineage/sql/ui/runtime/unit_signal_counts_2.sql new file mode 100644 index 000000000..641504e35 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/unit_signal_counts_2.sql @@ -0,0 +1,5 @@ +-- query-id: ui.runtime.unit_signal_counts_2.v1 +SELECT unit_id, COUNT(*) AS hazards + FROM unit_hazards + WHERE is_active = 1 + GROUP BY unit_id diff --git a/gems/lineage/sql/ui/runtime/unit_test_profiles.sql b/gems/lineage/sql/ui/runtime/unit_test_profiles.sql new file mode 100644 index 000000000..df81e1941 --- /dev/null +++ b/gems/lineage/sql/ui/runtime/unit_test_profiles.sql @@ -0,0 +1,3 @@ +-- query-id: ui.runtime.unit_test_profiles.v1 +SELECT id, current_line_cov, current_integration_cov, is_hard_gated + FROM logical_units diff --git a/gems/lineage/sql/ui/runtime/warning_units.sql b/gems/lineage/sql/ui/runtime/warning_units.sql new file mode 100644 index 000000000..8b8575acb --- /dev/null +++ b/gems/lineage/sql/ui/runtime/warning_units.sql @@ -0,0 +1,11 @@ +-- query-id: ui.runtime.warning_units.v1 +SELECT current_path, + current_distinct_tests, + current_mutant_verified_tests, + last_test_exposure_at, + last_mutant_run_at, + changes_after_test_exposure, + semantic_changes_after_mutant_run, + verification_stale_seconds, + reopened_count + FROM ui_warning_units diff --git a/gems/lineage/sql/ui/runtime/warning_units_2.sql b/gems/lineage/sql/ui/runtime/warning_units_2.sql new file mode 100644 index 000000000..354e7bf5a --- /dev/null +++ b/gems/lineage/sql/ui/runtime/warning_units_2.sql @@ -0,0 +1,93 @@ +-- query-id: ui.runtime.warning_units_2.v1 +WITH latest_events AS ( + SELECT unit_id, path + FROM ( + SELECT unit_id, path, + ROW_NUMBER() OVER ( + PARTITION BY unit_id + ORDER BY timestamp DESC, id DESC + ) AS rank + FROM events + ) + WHERE rank = 1 + ), + current_units AS ( + SELECT u.id, + COALESCE(le.path, u.original_path) AS current_path, + u.current_distinct_tests, + u.current_mutant_verified_tests, + u.last_test_exposure_at + FROM logical_units u + LEFT JOIN latest_events le ON le.unit_id = u.id + ), + db_clock AS ( + SELECT COALESCE(MAX(timestamp), 0) AS observed_at + FROM ( + SELECT timestamp FROM metadata + UNION ALL SELECT timestamp FROM events + UNION ALL SELECT timestamp FROM quality_events + UNION ALL SELECT timestamp FROM crash_events + UNION ALL SELECT timestamp FROM test_exposure_events + ) + ), + mutant_runs AS ( + SELECT unit_id, MAX(timestamp) AS last_mutant_run_at + FROM test_exposure_events + WHERE is_mutation_verified = 1 OR is_mutation_killed = 1 + GROUP BY unit_id + ), + event_counts AS ( + SELECT cu.id, + SUM(CASE + WHEN cu.last_test_exposure_at > 0 + AND e.semantic_change = 1 + AND e.event_type IN ('FIX', 'CHANGE') + AND e.timestamp > cu.last_test_exposure_at + THEN 1 ELSE 0 + END) AS changes_after_test_exposure, + SUM(CASE + WHEN COALESCE(m.last_mutant_run_at, 0) > 0 + AND e.semantic_change = 1 + AND e.event_type IN ('FIX', 'CHANGE') + AND e.timestamp > m.last_mutant_run_at + THEN 1 ELSE 0 + END) AS semantic_changes_after_mutant_run + FROM current_units cu + LEFT JOIN mutant_runs m ON m.unit_id = cu.id + LEFT JOIN events e ON e.unit_id = cu.id + GROUP BY cu.id + ), + reopened AS ( + SELECT c.unit_id, COUNT(DISTINCT c.id) AS reopened_count + FROM crash_events c + WHERE EXISTS ( + SELECT 1 + FROM events fix + WHERE fix.unit_id = c.unit_id + AND fix.event_type = 'FIX' + AND fix.semantic_change = 1 + AND fix.path = c.path + AND c.line BETWEEN fix.start_line AND fix.end_line + AND c.timestamp > fix.timestamp + ) + GROUP BY c.unit_id + ) + SELECT cu.current_path, + cu.current_distinct_tests, + cu.current_mutant_verified_tests, + cu.last_test_exposure_at, + COALESCE(m.last_mutant_run_at, 0) AS last_mutant_run_at, + COALESCE(ec.changes_after_test_exposure, 0) AS changes_after_test_exposure, + COALESCE(ec.semantic_changes_after_mutant_run, 0) AS semantic_changes_after_mutant_run, + CASE + WHEN COALESCE(m.last_mutant_run_at, 0) > 0 + AND clock.observed_at > m.last_mutant_run_at + THEN clock.observed_at - m.last_mutant_run_at + ELSE 0 + END AS verification_stale_seconds, + COALESCE(r.reopened_count, 0) AS reopened_count + FROM current_units cu + LEFT JOIN mutant_runs m ON m.unit_id = cu.id + LEFT JOIN event_counts ec ON ec.id = cu.id + LEFT JOIN reopened r ON r.unit_id = cu.id + CROSS JOIN db_clock clock diff --git a/gems/lineage/src/db/architecture.rs b/gems/lineage/src/db/architecture.rs new file mode 100644 index 000000000..69071e541 --- /dev/null +++ b/gems/lineage/src/db/architecture.rs @@ -0,0 +1,434 @@ +use crate::storage::Storage; +use anyhow::{bail, Context, Result}; +use rusqlite::{params, OptionalExtension}; +use serde_json::{json, Value}; + +const DELETE_SNAPSHOT_SQL: &str = include_str!("../../sql/architecture/delete_snapshot.sql"); +const INSERT_ARTIFACT_SQL: &str = include_str!("../../sql/architecture/insert_artifact.sql"); +const INSERT_NODE_SQL: &str = include_str!("../../sql/architecture/insert_node.sql"); +const INSERT_EDGE_SQL: &str = include_str!("../../sql/architecture/insert_edge.sql"); +const INSERT_EDGE_SPAN_SQL: &str = include_str!("../../sql/architecture/insert_edge_span.sql"); +const INSERT_PRESSURE_SQL: &str = include_str!("../../sql/architecture/insert_pressure.sql"); +const RECONCILE_LOGICAL_UNIT_SQL: &str = include_str!("../../sql/architecture/reconcile_logical_unit.sql"); +const SEARCH_SQL: &str = include_str!("../../sql/architecture/search.sql"); +const LATEST_ARTIFACT_SQL: &str = include_str!("../../sql/architecture/latest_artifact.sql"); +const ARTIFACT_HEALTH_SQL: &str = include_str!("../../sql/architecture/artifact_health.sql"); +const LOAD_NODE_SQL: &str = include_str!("../../sql/architecture/load_node.sql"); +const OWNER_INVENTORY_SQL: &str = include_str!("../../sql/architecture/owner_inventory.sql"); +const LOAD_EDGES_SQL: &str = include_str!("../../sql/architecture/load_edges.sql"); + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ArchitectureIngestStats { + pub artifacts: usize, + pub nodes: usize, + pub edges: usize, + pub spans: usize, + pub reconciled_units: usize, +} + +pub fn ingest_architecture_json( + storage: &Storage, + payload: &str, +) -> Result { + let document: Value = serde_json::from_str(payload).context("parse architecture artifact")?; + if document.get("kind").and_then(Value::as_str) != Some("espalier.architecture.v1") { + bail!("unsupported architecture artifact kind"); + } + let schema_version = document + .get("schema_version") + .and_then(Value::as_i64) + .unwrap_or(0); + if schema_version != 1 { + bail!("unsupported architecture schema version {schema_version}"); + } + let analyzer = document + .pointer("/analyzer/name") + .and_then(Value::as_str) + .unwrap_or("espalier"); + let analyzer_version = document + .pointer("/analyzer/version") + .and_then(Value::as_str) + .unwrap_or(""); + let commit = document + .pointer("/corpus/commit") + .and_then(Value::as_str) + .unwrap_or(""); + let root = document + .pointer("/corpus/root") + .and_then(Value::as_str) + .unwrap_or(""); + let complete = document + .pointer("/corpus/complete") + .and_then(Value::as_bool) + .unwrap_or(false); + let generated_at = document + .get("generated_at") + .and_then(Value::as_str) + .unwrap_or(""); + + let tx = storage.connection().unchecked_transaction()?; + tx.execute(DELETE_SNAPSHOT_SQL, params![analyzer, commit])?; + tx.execute( + INSERT_ARTIFACT_SQL, + params![analyzer, analyzer_version, schema_version, commit, root, complete as i64, generated_at, payload], + )?; + let artifact_id = tx.last_insert_rowid(); + let mut stats = ArchitectureIngestStats { + artifacts: 1, + ..ArchitectureIngestStats::default() + }; + + for node in document + .get("nodes") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let id = text(node, "id"); + let kind = text(node, "kind"); + let name = text(node, "name"); + let path = optional_text(node, "path"); + let start_line = integer(node, "start_line").max(0); + let logical_unit_id = if kind == "external" { + None + } else { + reconcile_logical_unit(&tx, path.as_deref(), &name, &kind, start_line)? + }; + if logical_unit_id.is_some() { + stats.reconciled_units += 1; + } + let metadata = node.get("metadata").cloned().unwrap_or_else(|| json!({})); + let confidence = metadata + .get("confidence") + .and_then(Value::as_str) + .unwrap_or("high"); + tx.execute( + INSERT_NODE_SQL, + params![ + artifact_id, id, logical_unit_id, optional_text(node, "owner_id"), kind, name, + optional_text(node, "owner"), optional_text(node, "language"), path, start_line, + integer(node, "start_column"), integer(node, "end_line"), integer(node, "end_column"), + confidence, metadata.to_string() + ], + )?; + stats.nodes += 1; + } + + for edge in document + .get("edges") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let edge_id = text(edge, "id"); + tx.execute( + INSERT_EDGE_SQL, + params![ + artifact_id, edge_id, text(edge, "source"), text(edge, "target"), text(edge, "kind"), + boolean(edge, "conditional") as i64, integer(edge, "weight").max(1), + optional_text(edge, "confidence").unwrap_or_else(|| "high".into()), + edge.get("metadata").cloned().unwrap_or_else(|| json!({})).to_string() + ], + )?; + stats.edges += 1; + for span in edge + .get("spans") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + tx.execute( + INSERT_EDGE_SPAN_SQL, + params![ + artifact_id, + edge_id, + text(span, "path"), + integer(span, "start_line"), + integer(span, "start_column"), + integer(span, "end_line"), + integer(span, "end_column") + ], + )?; + stats.spans += 1; + } + } + + for pressure in document + .get("pressure") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let components = pressure + .get("components") + .cloned() + .unwrap_or_else(|| json!({})); + tx.execute( + INSERT_PRESSURE_SQL, + params![artifact_id, text(pressure, "node_id"), number(pressure, "score"), text(pressure, "band"), + number(&components, "collaboration"), number(&components, "state"), + number(&components, "implementation"), number(&components, "operational"), + pressure.get("explanation").cloned().unwrap_or_else(|| json!({})).to_string()], + )?; + } + tx.commit()?; + Ok(stats) +} + +fn reconcile_logical_unit( + tx: &rusqlite::Transaction<'_>, + path: Option<&str>, + name: &str, + kind: &str, + start_line: i64, +) -> Result> { + let Some(path) = path else { return Ok(None) }; + let unit_kind = if kind == "owner" { + vec!["class", "module"] + } else { + vec![kind] + }; + let mut stmt = tx.prepare(RECONCILE_LOGICAL_UNIT_SQL)?; + let suffix = format!("%{name}"); + Ok(stmt + .query_row( + params![ + path, + name, + suffix, + unit_kind[0], + unit_kind.get(1).copied().unwrap_or(&unit_kind[0]), + start_line + ], + |row| row.get(0), + ) + .optional()?) +} + +pub fn owner_inventory(storage: &Storage, owner_id: &str) -> Result { + let artifact_id = latest_artifact_id(storage)?; + let owner = + load_node(storage, artifact_id, owner_id)?.context("architecture owner not found")?; + let members = load_nodes_for_owner(storage, artifact_id, owner_id)?; + Ok(json!({ + "artifact": artifact_health(storage, artifact_id)?, + "owner": owner, + "members": members + })) +} + +pub fn node_neighborhood(storage: &Storage, node_id: &str, limit: usize) -> Result { + let artifact_id = latest_artifact_id(storage)?; + let selected = + load_node(storage, artifact_id, node_id)?.context("architecture node not found")?; + let edges = load_edges(storage, artifact_id, node_id)?; + let mut ids = edges + .iter() + .flat_map(|edge| { + [ + edge.get("source").and_then(Value::as_str), + edge.get("target").and_then(Value::as_str), + ] + }) + .flatten() + .map(str::to_string) + .collect::>(); + ids.sort(); + ids.dedup(); + ids.retain(|id| id != node_id); + let visible_ids = ids + .iter() + .take(limit.saturating_sub(1)) + .cloned() + .collect::>(); + let mut nodes = vec![selected.clone()]; + for id in &visible_ids { + if let Some(node) = load_node(storage, artifact_id, id)? { + nodes.push(node); + } + } + let visible = visible_ids + .iter() + .chain(std::iter::once(&node_id.to_string())) + .cloned() + .collect::>(); + let mut visible_edges = edges + .iter() + .filter(|edge| { + visible.contains(edge["source"].as_str().unwrap_or_default()) + && visible.contains(edge["target"].as_str().unwrap_or_default()) + }) + .cloned() + .collect::>(); + let omitted_edges = edges + .iter() + .filter(|edge| { + !visible.contains(edge["source"].as_str().unwrap_or_default()) + || !visible.contains(edge["target"].as_str().unwrap_or_default()) + }) + .cloned() + .collect::>(); + if !omitted_edges.is_empty() { + let aggregate_id = format!("aggregate:{node_id}"); + nodes.push(json!({"id": aggregate_id, "kind": "aggregate", "name": format!("+{} relationships", omitted_edges.len()), + "confidence": "partial", "metadata": {"omitted": omitted_edges.len()}})); + visible_edges.push(json!({"id": format!("edge:{aggregate_id}"), "source": node_id, "target": aggregate_id, + "kind": "aggregate", "conditional": false, "weight": omitted_edges.len(), "confidence": "partial", "spans": []})); + } + Ok(json!({ + "artifact": artifact_health(storage, artifact_id)?, + "selected": selected, + "nodes": nodes, + "edges": visible_edges, + "total_relationships": edges.len(), + "omitted_relationships": omitted_edges + })) +} + +pub fn state_access(storage: &Storage, state_id: &str) -> Result { + node_neighborhood(storage, state_id, 100) +} + +pub fn architecture_search( + storage: &Storage, + owner_id: Option<&str>, + query: &str, +) -> Result { + let artifact_id = latest_artifact_id(storage)?; + let pattern = format!("%{}%", query.to_ascii_lowercase()); + let mut stmt = storage.connection().prepare(SEARCH_SQL)?; + let rows = stmt.query_map(params![artifact_id, pattern, owner_id], |row| { + Ok(json!({"id": row.get::<_, String>(0)?, "kind": row.get::<_, String>(1)?, "name": row.get::<_, String>(2)?, + "owner": row.get::<_, Option>(3)?, "path": row.get::<_, Option>(4)?, "start_line": row.get::<_, i64>(5)?, + "metadata": parse_json(row.get::<_, String>(6)?)})) + })?; + Ok(json!({"results": rows.collect::, _>>()?})) +} + +fn latest_artifact_id(storage: &Storage) -> Result { + storage + .connection() + .query_row(LATEST_ARTIFACT_SQL, [], |row| row.get(0)) + .context("no architecture artifact has been ingested") +} + +fn artifact_health(storage: &Storage, artifact_id: i64) -> Result { + storage.connection().query_row( + ARTIFACT_HEALTH_SQL, + params![artifact_id], + |row| Ok(json!({"id": artifact_id, "analyzer": row.get::<_, String>(0)?, "analyzer_version": row.get::<_, String>(1)?, + "schema_version": row.get::<_, i64>(2)?, "commit": row.get::<_, String>(3)?, "complete": row.get::<_, i64>(4)? != 0, + "generated_at": row.get::<_, String>(5)?})), + ).map_err(Into::into) +} + +fn load_node(storage: &Storage, artifact_id: i64, id: &str) -> Result> { + storage.connection().query_row( + LOAD_NODE_SQL, + params![artifact_id, id], node_from_row, + ).optional().map_err(Into::into) +} + +fn load_nodes_for_owner(storage: &Storage, artifact_id: i64, owner_id: &str) -> Result> { + let mut stmt = storage.connection().prepare(OWNER_INVENTORY_SQL)?; + let rows = stmt.query_map(params![artifact_id, owner_id], |row| { + let mut value = node_from_row(row)?; + value["pressure"] = json!({"score": row.get::<_, Option>(14)?.unwrap_or(0.0), "band": row.get::<_, Option>(15)?.unwrap_or_else(|| "ordinary".into()), "explanation": parse_json(row.get::<_, Option>(16)?.unwrap_or_else(|| "{}".into()))}); + value["incoming"] = json!(row.get::<_, i64>(17)?); + value["outgoing"] = json!(row.get::<_, i64>(18)?); + value["lineage"] = json!({"hazards": row.get::<_, i64>(19)?, "changes": row.get::<_, i64>(20)?, + "fixes": row.get::<_, i64>(21)?, "distinct_tests": row.get::<_, i64>(22)?, + "line_coverage": row.get::<_, f64>(23)?, "mutant_coverage": row.get::<_, f64>(24)?}); + Ok(value) + })?; + Ok(rows.collect::, _>>()?) +} + +fn node_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok( + json!({"id": row.get::<_, String>(0)?, "logical_unit_id": row.get::<_, Option>(1)?, "owner_id": row.get::<_, Option>(2)?, + "kind": row.get::<_, String>(3)?, "name": row.get::<_, String>(4)?, "owner": row.get::<_, Option>(5)?, + "language": row.get::<_, Option>(6)?, "path": row.get::<_, Option>(7)?, "start_line": row.get::<_, i64>(8)?, + "start_column": row.get::<_, i64>(9)?, "end_line": row.get::<_, i64>(10)?, "end_column": row.get::<_, i64>(11)?, + "confidence": row.get::<_, String>(12)?, "metadata": parse_json(row.get::<_, String>(13)?)}), + ) +} + +fn load_edges(storage: &Storage, artifact_id: i64, node_id: &str) -> Result> { + let mut stmt = storage.connection().prepare(LOAD_EDGES_SQL)?; + let rows = stmt.query_map(params![artifact_id, node_id], |row| Ok(json!({"id": row.get::<_, String>(0)?, "source": row.get::<_, String>(1)?, + "target": row.get::<_, String>(2)?, "kind": row.get::<_, String>(3)?, "conditional": row.get::<_, i64>(4)? != 0, + "weight": row.get::<_, i64>(5)?, "confidence": row.get::<_, String>(6)?, "metadata": parse_json(row.get::<_, String>(7)?), + "spans": parse_json(row.get::<_, String>(8)?)})))?; + Ok(rows.collect::, _>>()?) +} + +fn text(value: &Value, key: &str) -> String { + value + .get(key) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} +fn optional_text(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_str).map(str::to_string) +} +fn integer(value: &Value, key: &str) -> i64 { + value.get(key).and_then(Value::as_i64).unwrap_or(0) +} +fn number(value: &Value, key: &str) -> f64 { + value.get(key).and_then(Value::as_f64).unwrap_or(0.0) +} +fn boolean(value: &Value, key: &str) -> bool { + value.get(key).and_then(Value::as_bool).unwrap_or(false) +} +fn parse_json(text: String) -> Value { + serde_json::from_str(&text).unwrap_or_else(|_| json!({})) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standalone_architecture_sql_prepares_against_the_real_schema() { + let storage = Storage::open_memory().unwrap(); + for sql in [ + DELETE_SNAPSHOT_SQL, INSERT_ARTIFACT_SQL, INSERT_NODE_SQL, INSERT_EDGE_SQL, + INSERT_EDGE_SPAN_SQL, INSERT_PRESSURE_SQL, RECONCILE_LOGICAL_UNIT_SQL, + SEARCH_SQL, LATEST_ARTIFACT_SQL, ARTIFACT_HEALTH_SQL, LOAD_NODE_SQL, + OWNER_INVENTORY_SQL, LOAD_EDGES_SQL, + ] { + storage.connection().prepare(sql).unwrap_or_else(|error| { + panic!("standalone SQL failed to prepare: {error}\n{sql}") + }); + } + } + + #[test] + fn ingests_and_queries_focused_architecture() { + let storage = Storage::open_memory().unwrap(); + let payload = json!({ + "schema_version": 1, + "kind": "espalier.architecture.v1", + "analyzer": {"name": "espalier", "version": "test"}, + "generated_at": "2026-07-11T00:00:00Z", + "corpus": {"commit": "abc", "root": ".", "complete": true}, + "nodes": [ + {"id":"owner:1","kind":"owner","name":"Demo","owner":"Demo","path":"demo.rb","start_line":1,"start_column":0,"end_line":8,"end_column":3,"metadata":{"confidence":"high"}}, + {"id":"fn:1","kind":"function","name":"run","owner":"Demo","owner_id":"owner:1","path":"demo.rb","start_line":2,"start_column":0,"end_line":5,"end_column":3,"metadata":{"confidence":"high"}}, + {"id":"state:1","kind":"state","name":"@value","owner":"Demo","owner_id":"owner:1","path":"demo.rb","start_line":3,"start_column":2,"end_line":3,"end_column":8,"metadata":{"confidence":"high"}} + ], + "edges": [{"id":"edge:1","source":"fn:1","target":"state:1","kind":"writes","conditional":false,"weight":1,"confidence":"high","metadata":{},"spans":[{"path":"demo.rb","start_line":3,"start_column":2,"end_line":3,"end_column":8}]}], + "pressure": [{"node_id":"fn:1","score":60.0,"band":"orange","components":{"collaboration":0.2,"state":0.8,"implementation":0.0,"operational":0.0},"explanation":{"state_writes":1}}] + }).to_string(); + let stats = ingest_architecture_json(&storage, &payload).unwrap(); + assert_eq!(stats.nodes, 3); + assert_eq!(stats.edges, 1); + let inventory = owner_inventory(&storage, "owner:1").unwrap(); + assert_eq!(inventory["members"].as_array().unwrap().len(), 2); + assert_eq!(inventory["members"][0]["pressure"]["band"], "orange"); + let access = state_access(&storage, "state:1").unwrap(); + assert_eq!(access["total_relationships"], 1); + } +} diff --git a/gems/lineage/src/db/engine.rs b/gems/lineage/src/db/engine.rs index c8061297f..d0d32da37 100644 --- a/gems/lineage/src/db/engine.rs +++ b/gems/lineage/src/db/engine.rs @@ -127,6 +127,7 @@ where }) .collect(); + let mut last_commit_hash = None; for (commit_idx, commit) in commits.into_iter().enumerate().skip(start_idx) { self.storage.insert_metadata(&commit)?; let mut current = HashMap::new(); @@ -261,13 +262,16 @@ where .push(unit.id.clone()); } stats.commits += 1; + last_commit_hash = Some(commit.hash.clone()); + } + if let Some(hash) = last_commit_hash { let state = EngineState { previous: previous.clone(), aliases: aliases.clone(), }; let state_json = serde_json::to_string(&state)?; - self.storage.save_engine_state(&commit.hash, &state_json)?; + self.storage.save_engine_state(&hash, &state_json)?; } Ok(stats) @@ -324,7 +328,7 @@ fn is_meaningless_line(line: &str, path: &str) -> bool { match ext.as_str() { "rb" | "lua" => trimmed.eq_ignore_ascii_case("end"), "go" | "rs" | "c" | "h" | "cc" | "cpp" | "cxx" | "hh" | "hpp" | "hxx" | "cs" | "java" | "kt" | "kts" | "swift" | "js" | "jsx" | "ts" | "tsx" | "zig" | "php" => { - trimmed == "}" || trimmed == "{" || trimmed == "};" || trimmed == "{" || trimmed == "{}" + matches!(trimmed, "}" | "{" | "};" | "{}") } _ => false } @@ -362,15 +366,11 @@ fn is_valid_cross_file_move(prev: &LogicalUnit, current: &LogicalUnit) -> bool { let current_body_len = current_lines.len() - 1; // If it's a single line body statement, we require it to be non-trivial. - if prev_body_len == 1 { - if is_trivial_line(prev_lines[1]) { - return false; - } + if prev_body_len == 1 && is_trivial_line(prev_lines[1]) { + return false; } - if current_body_len == 1 { - if is_trivial_line(current_lines[1]) { - return false; - } + if current_body_len == 1 && is_trivial_line(current_lines[1]) { + return false; } true diff --git a/gems/lineage/src/db/extract.rs b/gems/lineage/src/db/extract.rs index 35c87bc12..77186cc84 100644 --- a/gems/lineage/src/db/extract.rs +++ b/gems/lineage/src/db/extract.rs @@ -6,7 +6,7 @@ use streaming_iterator::StreamingIterator; pub const DEFAULT_CODE_EXTENSIONS: &[&str] = &[ "rb", "zig", "py", "js", "jsx", "mjs", "cjs", "ts", "tsx", "lua", "c", "h", "cc", "cpp", - "cxx", "hh", "hpp", "hxx", "cs", "java", "swift", "kt", "kts", "go", "rs", "php", "S", + "cxx", "hh", "hpp", "hxx", "cs", "java", "swift", "kt", "kts", "go", "rs", "php", "sql", "S", ]; const DEFAULT_IGNORED_COMPONENTS: &[&str] = &[ ".git", @@ -177,6 +177,7 @@ impl BoundaryExtractor for HeuristicExtractor { let ext = extension(&file.path).map(|value| normalize_extension(&value)); let lines: Vec<&str> = file.contents.lines().collect(); let candidates = match ext.as_deref() { + Some("sql") => sql_candidates(file, &lines), Some(extension) if TreeSitterAdapter::for_extension(extension).is_some() => { tree_sitter_candidates(file, extension, &lines).unwrap_or_default() } @@ -217,6 +218,30 @@ impl BoundaryExtractor for HeuristicExtractor { } } +fn sql_candidates(file: &BlobFile, lines: &[&str]) -> Vec { + if lines.is_empty() { + return Vec::new(); + } + let query_id = lines.iter().find_map(|line| { + line.trim() + .strip_prefix("-- query-id:") + .map(str::trim) + .filter(|value| !value.is_empty()) + }); + let fallback = file.path.rsplit('/').next().unwrap_or(&file.path) + .strip_suffix(".sql").unwrap_or(&file.path); + let signature = lines.iter().map(|line| line.trim()) + .find(|line| !line.is_empty() && !line.starts_with("--")) + .unwrap_or("SQL query").to_string(); + vec![Candidate { + name: query_id.unwrap_or(fallback).to_string(), + kind: UnitKind::Function, + signature, + line: 1, + end_line: Some(lines.len() as u32), + }] +} + #[derive(Debug, Clone, Copy)] enum TreeSitterAdapter { C, @@ -865,6 +890,21 @@ fn normalize_extension(ext: &str) -> String { mod tests { use super::*; + #[test] + fn extracts_standalone_sql_query_as_a_logical_unit() { + let file = BlobFile { + path: "gems/lineage/sql/demo.sql".into(), + contents: "-- query-id: demo.lookup.v1\nSELECT value FROM demo WHERE value <> 0;\n".into(), + }; + let extractor = HeuristicExtractor::default(); + let units = extractor.extract_units(&file); + assert_eq!(units.len(), 1); + assert_eq!(units[0].name, "demo.lookup.v1"); + assert_eq!(units[0].start_line, 1); + assert_eq!(units[0].end_line, 2); + assert!(extractor.supports_path(&file.path)); + } + #[test] fn extracts_units_from_supported_languages() { let file = BlobFile { diff --git a/gems/lineage/src/db/hazard.rs b/gems/lineage/src/db/hazard.rs index 9cdb4164d..a4e21caa0 100644 --- a/gems/lineage/src/db/hazard.rs +++ b/gems/lineage/src/db/hazard.rs @@ -503,9 +503,7 @@ fn scan_zig_sites(path: &str, contents: &str) -> Vec { if vopr_ex { continue; } - if start_retry { - final_sites.push(site(path, line_no, line, "zig_vopr_retry", "vopr")); - } else if retry_direct { + if start_retry || retry_direct { final_sites.push(site(path, line_no, line, "zig_vopr_retry", "vopr")); } else if retry && !line.trim().is_empty() && !line.contains("VOPR-") { let has_structural_vopr = final_sites.iter().any(|s| s.line == line_no && s.hazard_type.starts_with("zig_vopr_")); diff --git a/gems/lineage/src/db/model.rs b/gems/lineage/src/db/model.rs index 2ceedf85b..26ce651dc 100644 --- a/gems/lineage/src/db/model.rs +++ b/gems/lineage/src/db/model.rs @@ -63,6 +63,7 @@ pub struct LogicalUnit { } impl LogicalUnit { + #[allow(clippy::too_many_arguments)] pub fn new( name: impl Into, kind: UnitKind, diff --git a/gems/lineage/src/db/mutant.rs b/gems/lineage/src/db/mutant.rs index 8240a9546..ad976aa84 100644 --- a/gems/lineage/src/db/mutant.rs +++ b/gems/lineage/src/db/mutant.rs @@ -33,6 +33,7 @@ pub struct MutantIngestStats { pub skipped_facts: usize, } +#[allow(clippy::too_many_arguments)] pub fn ingest_mutant_facts_json( storage: &Storage, normalizer: &dyn LanguageNormalizer, @@ -106,7 +107,7 @@ where for unit_match in matched_units { let path = &unit_match.path; let unit = &unit_match.unit; - let Some(unit_id) = storage.resolve_unit_id(&unit.id, &path, &unit.name)? else { + let Some(unit_id) = storage.resolve_unit_id(&unit.id, path, &unit.name)? else { stats.skipped_facts += 1; continue; }; diff --git a/gems/lineage/src/db/quality.rs b/gems/lineage/src/db/quality.rs index e83853f5f..a10d46775 100644 --- a/gems/lineage/src/db/quality.rs +++ b/gems/lineage/src/db/quality.rs @@ -5,6 +5,7 @@ use serde_json::{json, Value}; const DEFAULT_COVERAGE_SOURCE: &str = "coverage"; const RELATIVE_ROOTS: &[&str] = &[ + "compiler/", "src/", "gems/", "tools/", @@ -25,10 +26,12 @@ pub struct CoverageRecord { pub line_hits: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub struct CoverageLineHit { pub line: u32, pub hits: u32, + pub is_partial: bool, + pub coverage_percent: Option, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -169,12 +172,14 @@ fn ingest_records( )?; } for hit in &record.line_hits { - stats.line_events += usize::from(storage.record_coverage_line_with_source( + stats.line_events += usize::from(storage.record_coverage_line_with_details( commit_hash, timestamp, &path, hit.line, hit.hits, + hit.is_partial, + hit.coverage_percent, &options.line_source, )?); } @@ -196,11 +201,82 @@ pub fn parse_coverage_records(value: &Value, format: &str) -> Result Ok(parse_codecov_records(value)), "boobytrap" | "generic" => Ok(parse_generic_records(value)), + "sqlcov" | "sql-cov" => Ok(parse_sqlcov_records(value)), "simplecov" => Ok(parse_simplecov_records(value)), other => anyhow::bail!("unsupported coverage format {other:?}"), } } +fn parse_sqlcov_records(value: &Value) -> Vec { + let Some(path) = value.get("file_path").and_then(Value::as_str) else { return Vec::new() }; + let mut lines = std::collections::BTreeMap::::new(); + for statement in value.get("statements").and_then(Value::as_array).into_iter().flatten() { + let Some(start) = statement.get("start_line").and_then(Value::as_u64) + .and_then(|line| u32::try_from(line).ok()) else { continue }; + let end = statement.get("end_line").and_then(Value::as_u64) + .and_then(|line| u32::try_from(line).ok()).unwrap_or(start).max(start); + let hits = statement.get("hit_count").and_then(Value::as_u64).unwrap_or(0); + let hits = u32::try_from(hits).unwrap_or(u32::MAX); + for line in start..=end { + lines.insert(line, CoverageLineHit { + line, + hits, + is_partial: false, + coverage_percent: Some(if hits > 0 { 100.0 } else { 0.0 }), + }); + } + } + let metrics = value.get("metrics").and_then(Value::as_array).into_iter().flatten() + .filter(|metric| metric.get("measurable").and_then(Value::as_bool) != Some(false)); + let mut covered_branches = 0_u64; + let mut total_branches = 0_u64; + let mut branches_by_line = std::collections::BTreeMap::::new(); + for metric in metrics { + let Some(line) = metric.pointer("/span/start_line").and_then(Value::as_u64) + .and_then(|line| u32::try_from(line).ok()) else { continue }; + let nullable = metric.pointer("/span/nullable").and_then(Value::as_bool).unwrap_or(true); + let counts = [ + metric.get("hit_true_count").and_then(Value::as_u64).unwrap_or(0), + metric.get("hit_false_count").and_then(Value::as_u64).unwrap_or(0), + metric.get("hit_unknown_count").and_then(Value::as_u64).unwrap_or(0), + ]; + let branch_count = if nullable { 3 } else { 2 }; + let covered = counts.iter().take(branch_count).filter(|count| **count > 0).count() as u64; + covered_branches += covered; + total_branches += branch_count as u64; + let branch_totals = branches_by_line.entry(line).or_default(); + branch_totals.0 += covered; + branch_totals.1 += branch_count as u64; + let entry = lines.entry(line).or_insert(CoverageLineHit { + line, + hits: 0, + is_partial: false, + coverage_percent: Some(0.0), + }); + entry.is_partial |= covered < branch_count as u64; + } + for (line, (covered, total)) in branches_by_line { + if let Some(entry) = lines.get_mut(&line) { + entry.coverage_percent = (total > 0).then_some(covered as f64 * 100.0 / total as f64); + } + } + if lines.is_empty() { return Vec::new() } + let line_coverage = lines + .values() + .map(|line| line.coverage_percent.unwrap_or(if line.hits > 0 { 100.0 } else { 0.0 })) + .sum::() + / lines.len() as f64; + vec![CoverageRecord { + path: normalize_path(path), + line_coverage: Some(line_coverage), + integration_coverage: (total_branches > 0) + .then_some(covered_branches as f64 * 100.0 / total_branches as f64), + mutant_coverage: None, + hard_gated: None, + line_hits: lines.into_values().collect(), + }] +} + fn parse_codecov_records(value: &Value) -> Vec { let mut records = Vec::new(); if let Some(files) = value.get("files").and_then(Value::as_array) { @@ -299,6 +375,8 @@ fn parse_simplecov_records(value: &Value) -> Vec { hits.map(|hits| CoverageLineHit { line: (index + 1) as u32, hits, + is_partial: false, + coverage_percent: None, }) }) .collect::>(); @@ -383,7 +461,7 @@ fn line_hits_from_generic_node(node: &Value) -> Vec { if let Some(number) = line.as_u64() { return u32::try_from(number) .ok() - .map(|line| CoverageLineHit { line, hits: 1 }); + .map(|line| CoverageLineHit { line, hits: 1, is_partial: false, coverage_percent: None }); } let line_no = line @@ -401,6 +479,8 @@ fn line_hits_from_generic_node(node: &Value) -> Vec { Some(CoverageLineHit { line: line_no, hits, + is_partial: false, + coverage_percent: None, }) }) .collect() @@ -431,17 +511,14 @@ fn parse_cobertura_records(input: &str) -> Result> { let Some(raw_filename) = class.attribute("filename") else { continue; }; - let path = if !sources.is_empty() { - let mut resolved = String::new(); - for source in &sources { - let combined = if source.ends_with('/') || raw_filename.starts_with('/') { - format!("{}{}", source, raw_filename) - } else { - format!("{}/{}", source, raw_filename) - }; - resolved = combined; - break; - } + let path = if let Some(source) = sources.first() { + let resolved = if raw_filename.starts_with('/') { + raw_filename.to_string() + } else if source.ends_with('/') { + format!("{}{}", source, raw_filename) + } else { + format!("{}/{}", source, raw_filename) + }; normalize_path(&resolved) } else { normalize_path(raw_filename) @@ -456,9 +533,20 @@ fn parse_cobertura_records(input: &str) -> Result> { .filter_map(|line| { let line_no = line.attribute("number")?.parse::().ok()?; let hits = line.attribute("hits").unwrap_or("0").parse::().ok()?; + let is_partial = if line.attribute("branch").unwrap_or("false") == "true" { + if let Some(cc) = line.attribute("condition-coverage") { + !cc.starts_with("100%") + } else { + false + } + } else { + false + }; Some(CoverageLineHit { line: line_no, hits, + is_partial, + coverage_percent: None, }) }) .collect::>(); @@ -498,7 +586,7 @@ fn normalize_path(path: &str) -> String { let marker = format!("/{root}"); if let Some(index) = trimmed.find(&marker) { let actual_idx = index + 1; - if best_match.map_or(true, |(best_idx, _)| actual_idx < best_idx) { + if best_match.is_none_or(|(best_idx, _)| actual_idx < best_idx) { best_match = Some((actual_idx, root)); } } @@ -597,6 +685,30 @@ fn record_metric( #[cfg(test)] mod tests { use super::*; + + #[test] + fn parses_sqlcov_branch_states_as_partial_line_coverage() { + let payload = serde_json::json!({ + "format": "sql-cov/v1", "file_path": "gems/lineage/sql/demo.sql", + "statements": [{ "start_line": 2, "end_line": 3, "hit_count": 1 }], + "metrics": [{ + "measurable": true, "span": { "start_line": 2, "nullable": true }, + "hit_true_count": 3, "hit_false_count": 1, "hit_unknown_count": 0 + }, { + "measurable": false, "span": { "start_line": 3, "nullable": true }, + "hit_true_count": 0, "hit_false_count": 0, "hit_unknown_count": 0 + }] + }); + let records = parse_coverage_records(&payload, "sqlcov").unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].path, "gems/lineage/sql/demo.sql"); + assert!((records[0].line_coverage.unwrap() - 250.0 / 3.0).abs() < 0.000_001); + assert_eq!(records[0].integration_coverage, Some(200.0 / 3.0)); + assert_eq!(records[0].line_hits, vec![ + CoverageLineHit { line: 2, hits: 1, is_partial: true, coverage_percent: Some(200.0 / 3.0) }, + CoverageLineHit { line: 3, hits: 1, is_partial: false, coverage_percent: Some(100.0) }, + ]); + } use crate::model::{CommitMetadata, LogicalUnit, UnitKind}; use serde_json::json; @@ -659,8 +771,8 @@ mod tests { let records = parse_coverage_records(&value, "generic").unwrap(); assert_eq!(records.len(), 1); - assert_eq!(records[0].line_hits[0], CoverageLineHit { line: 1, hits: 2 }); - assert_eq!(records[0].line_hits[1], CoverageLineHit { line: 2, hits: 0 }); + assert_eq!(records[0].line_hits[0], CoverageLineHit { line: 1, hits: 2, is_partial: false, coverage_percent: None }); + assert_eq!(records[0].line_hits[1], CoverageLineHit { line: 2, hits: 0, is_partial: false, coverage_percent: None }); } #[test] @@ -684,8 +796,8 @@ mod tests { assert_eq!(records.len(), 1); assert_eq!(records[0].path, "src/demo.rb"); assert_eq!(records[0].line_coverage, Some(50.0)); - assert_eq!(records[0].line_hits[0], CoverageLineHit { line: 1, hits: 3 }); - assert_eq!(records[0].line_hits[1], CoverageLineHit { line: 2, hits: 0 }); + assert_eq!(records[0].line_hits[0], CoverageLineHit { line: 1, hits: 3, is_partial: false, coverage_percent: None }); + assert_eq!(records[0].line_hits[1], CoverageLineHit { line: 2, hits: 0, is_partial: false, coverage_percent: None }); } #[test] @@ -739,9 +851,9 @@ mod tests { assert_eq!( records[0].line_hits, vec![ - CoverageLineHit { line: 2, hits: 2 }, - CoverageLineHit { line: 3, hits: 3 }, - CoverageLineHit { line: 5, hits: 1 }, + CoverageLineHit { line: 2, hits: 2, is_partial: false, coverage_percent: None }, + CoverageLineHit { line: 3, hits: 3, is_partial: false, coverage_percent: None }, + CoverageLineHit { line: 5, hits: 1, is_partial: false, coverage_percent: None }, ] ); assert_eq!(records[0].line_coverage, Some(100.0)); @@ -756,8 +868,8 @@ mod tests { mutant_coverage: None, hard_gated: None, line_hits: vec![ - CoverageLineHit { line: 1, hits: 2 }, - CoverageLineHit { line: 2, hits: 0 }, + CoverageLineHit { line: 1, hits: 2, is_partial: false, coverage_percent: None }, + CoverageLineHit { line: 2, hits: 0, is_partial: false, coverage_percent: None }, ], }]; diff --git a/gems/lineage/src/db/sarif.rs b/gems/lineage/src/db/sarif.rs index 83b4d49d9..4cdebd4dc 100644 --- a/gems/lineage/src/db/sarif.rs +++ b/gems/lineage/src/db/sarif.rs @@ -371,6 +371,9 @@ fn is_dark_arm_result( message: &str, category: &str, ) -> bool { + if result_is_explicitly_dead(result, properties, rule_id, category) { + return false; + } if properties .get("dark_arm") .and_then(Value::as_bool) @@ -391,6 +394,32 @@ fn is_dark_arm_result( haystack.contains("dark-arm") || haystack.contains("dark arm") } +fn result_is_explicitly_dead( + result: &Value, + properties: &Value, + rule_id: &str, + category: &str, +) -> bool { + let property_is_dead = ["category", "arm_category", "kind", "type", "status", "classification"] + .iter() + .filter_map(|key| properties.get(key).and_then(Value::as_str)) + .any(|value| value.eq_ignore_ascii_case("dead")); + if property_is_dead || category.eq_ignore_ascii_case("dead") { + return true; + } + + [ + rule_id, + result.get("ruleId").and_then(Value::as_str).unwrap_or(""), + ] + .iter() + .any(|value| { + value + .split(|character: char| !character.is_ascii_alphanumeric()) + .any(|segment| segment.eq_ignore_ascii_case("dead")) + }) +} + fn partial_fingerprint(result: &Value) -> Option { let fingerprints = result.get("partialFingerprints")?.as_object()?; let mut parts = fingerprints @@ -450,6 +479,27 @@ mod tests { use crate::model::{LogicalUnit, UnitKind}; use tempfile::tempdir; + #[test] + fn dead_classification_is_exact_and_does_not_match_deadline() { + let deadline = serde_json::json!({"ruleId": "slopcop.dark-arm"}); + assert!(is_dark_arm_result( + &deadline, + &serde_json::json!({}), + "slopcop.dark-arm", + "dark arm can miss a deadline", + "dark-arm", + )); + + let dead = serde_json::json!({"ruleId": "slopcop.dark-arm.dead"}); + assert!(!is_dark_arm_result( + &dead, + &serde_json::json!({"dark_arm": true, "kind": "dead"}), + "slopcop.dark-arm.dead", + "dark arm", + "dead", + )); + } + #[test] fn ingests_sarif_findings_and_replaces_idempotently() { let dir = tempdir().unwrap(); @@ -510,7 +560,7 @@ mod tests { let stats = ingest_sarif_paths( &storage, dir.path(), - &[sarif.clone()], + std::slice::from_ref(&sarif), "slopcop", "abc", Some(20), @@ -536,11 +586,110 @@ mod tests { assert_eq!(storage.count_rows("sarif_findings").unwrap(), 1); } + #[test] + fn current_snapshot_and_lifecycle_ignore_stale_findings() { + let dir = tempdir().unwrap(); + let storage = Storage::open_memory().unwrap(); + let sarif = dir.path().join("report.sarif"); + let result = |rule: &str, message: &str| { + serde_json::json!({ + "ruleId": rule, + "level": "warning", + "message": {"text": message}, + "locations": [{"physicalLocation": { + "artifactLocation": {"uri": "src/demo.rb"}, + "region": {"startLine": 2} + }}], + "partialFingerprints": {"stable": message} + }) + }; + let document = |results: Vec| { + serde_json::json!({ + "version": "2.1.0", + "runs": [{ + "tool": {"driver": {"name": "Decomplex"}}, + "results": results, + "properties": {"format": "decomplex.report.sarif.v1"} + }] + }) + }; + + fs::write( + &sarif, + serde_json::to_vec(&document(vec![ + result("decomplex.old", "resolved"), + result("decomplex.same", "persisted"), + serde_json::json!({ + "ruleId": "decomplex.other", + "level": "warning", + "message": {"text": "other file retained"}, + "locations": [{"physicalLocation": { + "artifactLocation": {"uri": "src/other.rb"}, + "region": {"startLine": 1} + }}], + "partialFingerprints": {"stable": "other file retained"} + }), + ])) + .unwrap(), + ) + .unwrap(); + ingest_sarif_paths( + &storage, + dir.path(), + std::slice::from_ref(&sarif), + "decomplex", + "old", + Some(10), + false, + ) + .unwrap(); + + fs::write( + &sarif, + serde_json::to_vec(&document(vec![ + result("decomplex.same", "persisted"), + result("decomplex.new", "new"), + ])) + .unwrap(), + ) + .unwrap(); + ingest_sarif_paths( + &storage, + dir.path(), + &[sarif], + "decomplex", + "new", + Some(20), + false, + ) + .unwrap(); + + assert_eq!(storage.count_rows("sarif_findings").unwrap(), 5); + let current = storage.sarif_findings_for_path("src/demo.rb").unwrap(); + assert_eq!(current.len(), 2); + assert!(current.iter().all(|finding| finding.commit_hash == "new")); + let retained = storage.sarif_findings_for_path("src/other.rb").unwrap(); + assert_eq!(retained.len(), 1); + assert_eq!(retained[0].commit_hash, "old"); + assert_eq!( + storage.sarif_finding_counts_by_file().unwrap()["src/demo.rb"], + 2 + ); + assert_eq!( + storage.sarif_lifecycle_summary().unwrap(), + crate::storage::SarifLifecycleSummary { + new_findings: 2, + resolved_findings: 1, + persisted_findings: 1, + } + ); + } + #[test] fn test_sarif_ingest_edge_cases() { let dir = tempdir().unwrap(); let storage = Storage::open_memory().unwrap(); - + let u1 = LogicalUnit::new( "U1".to_string(), UnitKind::Function, @@ -566,16 +715,16 @@ mod tests { let sub = dir.path().join("sub"); fs::create_dir(&sub).unwrap(); - + let sarif1 = sub.join("report1.sarif"); fs::write(&sarif1, "{}").unwrap(); let json1 = sub.join("report2.json"); fs::write(&json1, "{}").unwrap(); let txt1 = sub.join("report3.txt"); fs::write(&txt1, "{}").unwrap(); - + let bad_path = dir.path().join("does_not_exist"); - + let stats = ingest_sarif_paths( &storage, dir.path(), @@ -585,9 +734,9 @@ mod tests { None, false, ).unwrap(); - - assert_eq!(stats.skipped_files, 2); - + + assert_eq!(stats.skipped_files, 2); + let invalid_json = sub.join("invalid.json"); fs::write(&invalid_json, "invalid JSON content").unwrap(); let stats2 = ingest_sarif_paths( @@ -632,7 +781,7 @@ mod tests { }] }"#, ).unwrap(); - + let stats3 = ingest_sarif_paths( &storage, dir.path(), @@ -642,8 +791,45 @@ mod tests { None, false, ).unwrap(); - + assert_eq!(stats3.artifacts, 1); assert_eq!(stats3.findings, 2); + + let dead_json = dir.path().join("dead_test.sarif"); + fs::write( + &dead_json, + r#"{ + "version":"2.1.0", + "runs":[{ + "tool":{"driver":{"name":"SlopCop"}}, + "results":[{ + "ruleId":"slopcop.dark-arm.dead", + "level":"warning", + "message":{"text":"dark arm: dead"}, + "locations":[{ + "physicalLocation":{ + "artifactLocation":{"uri":"src/demo.rb"} + } + }], + "properties":{"kind":"dead"} + }] + }] + }"#, + ).unwrap(); + + let stats4 = ingest_sarif_paths( + &storage, + dir.path(), + &[dead_json], + "test_source", + "abc", + None, + false, + ).unwrap(); + + assert_eq!(stats4.findings, 1); + let findings = storage.sarif_findings_for_path("src/demo.rb").unwrap(); + let dead_finding = findings.iter().find(|f| f.rule_id == "slopcop.dark-arm.dead").unwrap(); + assert!(!dead_finding.is_dark_arm); } } diff --git a/gems/lineage/src/db/stack_trace.rs b/gems/lineage/src/db/stack_trace.rs index 9eb77e0ef..bc9db9675 100644 --- a/gems/lineage/src/db/stack_trace.rs +++ b/gems/lineage/src/db/stack_trace.rs @@ -84,10 +84,10 @@ impl LanguageNormalizer for RepoPathNormalizer { } if path.starts_with('/') { let mut best_match: Option<(usize, &str)> = None; - for marker in ["/src/", "/gems/", "/zig/"] { + for marker in ["/compiler/", "/src/", "/gems/", "/zig/"] { if let Some(index) = path.find(marker) { let actual_idx = index + 1; - if best_match.map_or(true, |(best_idx, _)| actual_idx < best_idx) { + if best_match.is_none_or(|(best_idx, _)| actual_idx < best_idx) { best_match = Some((actual_idx, marker)); } } @@ -537,4 +537,3 @@ mod tests { assert_eq!(normalizer.normalize_path("./foo.rb"), "foo.rb"); } } - diff --git a/gems/lineage/src/db/storage.rs b/gems/lineage/src/db/storage.rs index 0b5c31b88..99a71ae14 100644 --- a/gems/lineage/src/db/storage.rs +++ b/gems/lineage/src/db/storage.rs @@ -19,6 +19,13 @@ pub struct CurrentUnitSpan { pub end_line: u32, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SarifLifecycleSummary { + pub new_findings: i64, + pub resolved_findings: i64, + pub persisted_findings: i64, +} + #[derive(Debug, Clone, PartialEq)] pub struct UnitSummary { pub id: String, @@ -72,256 +79,7 @@ impl Storage { pub fn init_schema(&self) -> Result<()> { self.conn.execute_batch( - r#" - PRAGMA foreign_keys = ON; - PRAGMA synchronous = NORMAL; - - CREATE TABLE IF NOT EXISTS logical_units ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL, - original_path TEXT NOT NULL, - created_at INTEGER NOT NULL, - start_line INTEGER DEFAULT 1, - current_line_cov REAL DEFAULT 0.0, - current_integration_cov REAL DEFAULT 0.0, - current_mutant_cov REAL DEFAULT 0.0, - is_hard_gated INTEGER DEFAULT 0, - current_distinct_tests INTEGER DEFAULT 0, - current_test_types TEXT DEFAULT '', - current_mutant_verified_tests INTEGER DEFAULT 0, - current_mutant_killed_tests INTEGER DEFAULT 0, - last_test_exposure_at INTEGER DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - unit_id TEXT NOT NULL, - commit_hash TEXT NOT NULL, - event_type TEXT NOT NULL CHECK (event_type IN ('CHANGE', 'MOVE', 'FIX')), - path TEXT NOT NULL, - name TEXT NOT NULL, - start_line INTEGER NOT NULL, - end_line INTEGER NOT NULL, - semantic_change INTEGER NOT NULL CHECK (semantic_change IN (0, 1)), - lines_added INTEGER NOT NULL DEFAULT 0, - lines_removed INTEGER NOT NULL DEFAULT 0, - timestamp INTEGER NOT NULL, - FOREIGN KEY(unit_id) REFERENCES logical_units(id) - ); - - CREATE TABLE IF NOT EXISTS metadata ( - commit_hash TEXT PRIMARY KEY, - message TEXT NOT NULL, - sentry_id TEXT, - coverage_delta REAL, - timestamp INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS quality_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - unit_id TEXT NOT NULL, - commit_hash TEXT NOT NULL, - timestamp INTEGER NOT NULL, - metric_type TEXT NOT NULL CHECK ( - metric_type IN ('LINE_COV', 'INTEGRATION_COV', 'MUTANT_COV', 'GATE_STATUS') - ), - old_value REAL, - new_value REAL NOT NULL, - FOREIGN KEY(unit_id) REFERENCES logical_units(id) - ); - - CREATE TABLE IF NOT EXISTS crash_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - unit_id TEXT NOT NULL, - commit_hash TEXT NOT NULL, - timestamp INTEGER NOT NULL, - error_class TEXT NOT NULL, - provider_id TEXT NOT NULL, - is_verified INTEGER NOT NULL CHECK (is_verified IN (0, 1)), - path TEXT NOT NULL, - line INTEGER NOT NULL, - function TEXT NOT NULL, - FOREIGN KEY(unit_id) REFERENCES logical_units(id) - ); - - CREATE TABLE IF NOT EXISTS test_exposure_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - unit_id TEXT NOT NULL, - commit_hash TEXT NOT NULL, - timestamp INTEGER NOT NULL, - path TEXT NOT NULL, - function TEXT, - line INTEGER, - branch_id TEXT, - test_id TEXT NOT NULL, - test_type TEXT NOT NULL, - mutation_status TEXT, - mutation_kind TEXT NOT NULL DEFAULT '', - is_mutation_verified INTEGER NOT NULL CHECK (is_mutation_verified IN (0, 1)), - is_mutation_killed INTEGER NOT NULL CHECK (is_mutation_killed IN (0, 1)), - is_verified INTEGER NOT NULL CHECK (is_verified IN (0, 1)), - payload_json TEXT NOT NULL, - FOREIGN KEY(unit_id) REFERENCES logical_units(id) - ); - - CREATE TABLE IF NOT EXISTS unit_hazards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - unit_id TEXT NOT NULL, - language TEXT NOT NULL, - hazard_type TEXT NOT NULL, - required_evidence TEXT NOT NULL, - path TEXT NOT NULL, - line INTEGER NOT NULL, - symbol TEXT, - source TEXT NOT NULL, - detected_at_hash TEXT NOT NULL, - is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)), - payload_json TEXT NOT NULL, - FOREIGN KEY(unit_id) REFERENCES logical_units(id) - ); - - CREATE TABLE IF NOT EXISTS coverage_line_events ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - commit_hash TEXT NOT NULL, - timestamp INTEGER NOT NULL, - path TEXT NOT NULL, - line INTEGER NOT NULL, - hits INTEGER NOT NULL, - source TEXT NOT NULL DEFAULT 'coverage', - UNIQUE(commit_hash, path, line, source) - ); - - CREATE TABLE IF NOT EXISTS sarif_artifacts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source TEXT NOT NULL, - tool_name TEXT NOT NULL, - run_format TEXT NOT NULL, - artifact_path TEXT NOT NULL, - artifact_sha256 TEXT NOT NULL, - commit_hash TEXT NOT NULL, - timestamp INTEGER NOT NULL, - payload_json TEXT NOT NULL, - ingested_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), - UNIQUE(source, commit_hash, artifact_path, artifact_sha256) - ); - - CREATE TABLE IF NOT EXISTS sarif_findings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - artifact_id INTEGER NOT NULL, - finding_key TEXT NOT NULL, - source TEXT NOT NULL, - tool_name TEXT NOT NULL, - run_format TEXT NOT NULL, - commit_hash TEXT NOT NULL, - timestamp INTEGER NOT NULL, - rule_id TEXT NOT NULL, - level TEXT NOT NULL, - message TEXT NOT NULL, - path TEXT NOT NULL, - start_line INTEGER NOT NULL, - start_column INTEGER, - end_line INTEGER, - end_column INTEGER, - category TEXT NOT NULL, - is_dark_arm INTEGER NOT NULL CHECK (is_dark_arm IN (0, 1)), - unit_id TEXT, - fingerprint TEXT NOT NULL, - properties_json TEXT NOT NULL, - raw_json TEXT NOT NULL, - FOREIGN KEY(artifact_id) REFERENCES sarif_artifacts(id) ON DELETE CASCADE, - FOREIGN KEY(unit_id) REFERENCES logical_units(id), - UNIQUE(source, commit_hash, finding_key) - ); - - CREATE TABLE IF NOT EXISTS ui_file_summaries ( - path TEXT PRIMARY KEY, - units INTEGER NOT NULL, - hazards INTEGER NOT NULL, - evidence_covered_hazards INTEGER NOT NULL, - covered_hazards INTEGER NOT NULL, - distinct_tests INTEGER NOT NULL, - mutant_killed_tests INTEGER NOT NULL, - tracked_lines INTEGER NOT NULL, - covered_lines INTEGER NOT NULL, - line_coverage REAL NOT NULL, - mutant_coverage REAL NOT NULL, - mutant_verified_covered_lines INTEGER NOT NULL, - mutant_killed_covered_lines INTEGER NOT NULL, - stochastic_mutant_verified_covered_lines INTEGER NOT NULL, - stochastic_mutant_killed_covered_lines INTEGER NOT NULL, - invariant_mutant_verified_covered_lines INTEGER NOT NULL, - invariant_mutant_killed_covered_lines INTEGER NOT NULL, - multi_type_covered_lines INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS ui_warning_units ( - unit_id TEXT PRIMARY KEY, - current_path TEXT NOT NULL, - current_distinct_tests INTEGER NOT NULL, - current_mutant_verified_tests INTEGER NOT NULL, - last_test_exposure_at INTEGER NOT NULL, - last_mutant_run_at INTEGER NOT NULL, - changes_after_test_exposure INTEGER NOT NULL, - semantic_changes_after_mutant_run INTEGER NOT NULL, - verification_stale_seconds INTEGER NOT NULL, - reopened_count INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS ui_refresh_metadata ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS engine_state ( - commit_hash TEXT PRIMARY KEY, - state_json TEXT NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_events_unit_id ON events(unit_id); - CREATE INDEX IF NOT EXISTS idx_events_unit_latest - ON events(unit_id, timestamp DESC, id DESC); - CREATE INDEX IF NOT EXISTS idx_events_unit_type_semantic_time - ON events(unit_id, event_type, semantic_change, timestamp); - CREATE INDEX IF NOT EXISTS idx_events_commit_hash ON events(commit_hash); - CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type); - CREATE INDEX IF NOT EXISTS idx_quality_events_unit_id ON quality_events(unit_id); - CREATE INDEX IF NOT EXISTS idx_quality_events_commit_hash ON quality_events(commit_hash); - CREATE INDEX IF NOT EXISTS idx_crash_events_unit_id ON crash_events(unit_id); - CREATE INDEX IF NOT EXISTS idx_crash_events_unit_path_line_time - ON crash_events(unit_id, path, line, timestamp); - CREATE INDEX IF NOT EXISTS idx_crash_events_commit_hash ON crash_events(commit_hash); - CREATE INDEX IF NOT EXISTS idx_test_exposure_events_unit_id ON test_exposure_events(unit_id); - CREATE INDEX IF NOT EXISTS idx_test_exposure_events_unit_mutant_time - ON test_exposure_events(unit_id, is_mutation_verified, is_mutation_killed, timestamp); - CREATE INDEX IF NOT EXISTS idx_test_exposure_events_path_line_latest - ON test_exposure_events(path, line, branch_id, test_id, test_type, timestamp DESC, id DESC); - CREATE INDEX IF NOT EXISTS idx_test_exposure_events_commit_hash ON test_exposure_events(commit_hash); - CREATE INDEX IF NOT EXISTS idx_test_exposure_events_test_id ON test_exposure_events(test_id); - CREATE INDEX IF NOT EXISTS idx_test_exposure_events_type ON test_exposure_events(test_type); - CREATE INDEX IF NOT EXISTS idx_unit_hazards_unit_id ON unit_hazards(unit_id); - CREATE INDEX IF NOT EXISTS idx_unit_hazards_path_line ON unit_hazards(path, line); - CREATE INDEX IF NOT EXISTS idx_unit_hazards_type ON unit_hazards(hazard_type); - CREATE INDEX IF NOT EXISTS idx_unit_hazards_detected_at ON unit_hazards(detected_at_hash); - CREATE INDEX IF NOT EXISTS idx_coverage_line_events_path_line ON coverage_line_events(path, line); - CREATE INDEX IF NOT EXISTS idx_coverage_line_events_path_line_source_latest - ON coverage_line_events(path, line, source, timestamp DESC, id DESC); - CREATE INDEX IF NOT EXISTS idx_coverage_line_events_commit_hash ON coverage_line_events(commit_hash); - CREATE INDEX IF NOT EXISTS idx_sarif_artifacts_source_commit - ON sarif_artifacts(source, commit_hash); - CREATE INDEX IF NOT EXISTS idx_sarif_findings_path_line - ON sarif_findings(path, start_line); - CREATE INDEX IF NOT EXISTS idx_sarif_findings_source_commit - ON sarif_findings(source, commit_hash); - CREATE INDEX IF NOT EXISTS idx_sarif_findings_unit_id - ON sarif_findings(unit_id); - CREATE INDEX IF NOT EXISTS idx_sarif_findings_rule_id - ON sarif_findings(rule_id); - CREATE INDEX IF NOT EXISTS idx_ui_file_summaries_path ON ui_file_summaries(path); - CREATE INDEX IF NOT EXISTS idx_ui_warning_units_path ON ui_warning_units(current_path); - CREATE INDEX IF NOT EXISTS idx_events_path ON events(path); - CREATE INDEX IF NOT EXISTS idx_logical_units_original_path ON logical_units(original_path); - "#, + include_str!("../../sql/storage/init_schema.sql"), )?; self.ensure_logical_unit_column("start_line", "INTEGER DEFAULT 1")?; self.ensure_logical_unit_column("current_line_cov", "REAL DEFAULT 0.0")?; @@ -334,6 +92,17 @@ impl Storage { self.ensure_logical_unit_column("current_mutant_killed_tests", "INTEGER DEFAULT 0")?; self.ensure_logical_unit_column("last_test_exposure_at", "INTEGER DEFAULT 0")?; self.ensure_column("test_exposure_events", "mutation_kind", "TEXT NOT NULL DEFAULT ''")?; + self.ensure_column( + "coverage_line_events", + "is_partial", + "INTEGER NOT NULL DEFAULT 0", + )?; + self.ensure_column("coverage_line_events", "coverage_percent", "REAL")?; + self.ensure_column( + "ui_file_summaries", + "partial_lines", + "INTEGER NOT NULL DEFAULT 0", + )?; self.ensure_column( "ui_file_summaries", "mutant_verified_covered_lines", @@ -349,6 +118,7 @@ impl Storage { "invariant_mutant_verified_covered_lines", "INTEGER NOT NULL DEFAULT 0", )?; + self.refresh_current_sarif_findings_view()?; self.backfill_mutation_kind()?; self.conn.execute( "CREATE INDEX IF NOT EXISTS idx_test_exposure_events_mutation_kind ON test_exposure_events(mutation_kind)", @@ -366,6 +136,13 @@ impl Storage { self.ensure_column("logical_units", name, definition) } + fn refresh_current_sarif_findings_view(&self) -> Result<()> { + self.conn.execute_batch( + include_str!("../../sql/storage/refresh_current_sarif_findings_view.sql"), + )?; + Ok(()) + } + fn ensure_column(&self, table: &str, name: &str, definition: &str) -> Result<()> { let table = checked_table(table)?; let mut stmt = self.conn.prepare(&format!("PRAGMA table_info({table})"))?; @@ -384,122 +161,14 @@ impl Storage { fn ensure_natural_key_indexes(&self) -> Result<()> { self.conn.execute_batch( - r#" - DELETE FROM coverage_line_events - WHERE id NOT IN ( - SELECT ( - SELECT c2.id - FROM coverage_line_events c2 - WHERE c2.commit_hash = c1.commit_hash - AND c2.path = c1.path - AND c2.line = c1.line - AND c2.source = c1.source - ORDER BY c2.hits DESC, c2.timestamp DESC, c2.id DESC - LIMIT 1 - ) - FROM coverage_line_events c1 - GROUP BY c1.commit_hash, c1.path, c1.line, c1.source - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_coverage_line_events_natural_key - ON coverage_line_events(commit_hash, path, line, source); - - DELETE FROM quality_events - WHERE id NOT IN ( - SELECT ( - SELECT q2.id - FROM quality_events q2 - WHERE q2.unit_id = q1.unit_id - AND q2.commit_hash = q1.commit_hash - AND q2.metric_type = q1.metric_type - ORDER BY q2.new_value DESC, q2.timestamp DESC, q2.id DESC - LIMIT 1 - ) - FROM quality_events q1 - GROUP BY q1.unit_id, q1.commit_hash, q1.metric_type - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_quality_events_natural_key - ON quality_events(unit_id, commit_hash, metric_type); - - DELETE FROM crash_events - WHERE id NOT IN ( - SELECT ( - SELECT e2.id - FROM crash_events e2 - WHERE e2.unit_id = e1.unit_id - AND e2.commit_hash = e1.commit_hash - AND e2.error_class = e1.error_class - AND e2.provider_id = e1.provider_id - AND e2.path = e1.path - AND e2.line = e1.line - AND e2.function = e1.function - ORDER BY e2.is_verified DESC, e2.timestamp DESC, e2.id DESC - LIMIT 1 - ) - FROM crash_events e1 - GROUP BY e1.unit_id, e1.commit_hash, e1.error_class, e1.provider_id, - e1.path, e1.line, e1.function - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_crash_events_natural_key - ON crash_events(unit_id, commit_hash, error_class, provider_id, path, line, function); - - DELETE FROM test_exposure_events - WHERE id NOT IN ( - SELECT ( - SELECT t2.id - FROM test_exposure_events t2 - WHERE t2.unit_id = t1.unit_id - AND t2.commit_hash = t1.commit_hash - AND t2.path = t1.path - AND COALESCE(t2.line, -1) = COALESCE(t1.line, -1) - AND COALESCE(t2.branch_id, '') = COALESCE(t1.branch_id, '') - AND t2.test_id = t1.test_id - AND t2.test_type = t1.test_type - ORDER BY t2.is_verified DESC, - t2.is_mutation_killed DESC, - t2.is_mutation_verified DESC, - CASE - WHEN lower(COALESCE(t2.mutation_kind, '')) IN ('invariant', 'contract') THEN 2 - WHEN COALESCE(t2.mutation_kind, '') <> '' THEN 1 - ELSE 0 - END DESC, - t2.timestamp DESC, - t2.id DESC - LIMIT 1 - ) - FROM test_exposure_events t1 - GROUP BY t1.unit_id, t1.commit_hash, t1.path, COALESCE(t1.line, -1), - COALESCE(t1.branch_id, ''), t1.test_id, t1.test_type - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_test_exposure_events_natural_key - ON test_exposure_events( - unit_id, - commit_hash, - path, - COALESCE(line, -1), - COALESCE(branch_id, ''), - test_id, - test_type - ); - "#, + include_str!("../../sql/storage/ensure_natural_key_indexes.sql"), )?; Ok(()) } fn backfill_mutation_kind(&self) -> Result<()> { self.conn.execute( - r#" - UPDATE test_exposure_events - SET mutation_kind = CASE - WHEN lower(COALESCE(test_type, '') || ' ' || COALESCE(test_id, '')) LIKE '%invariant%' - OR lower(COALESCE(test_type, '') || ' ' || COALESCE(test_id, '')) LIKE '%contract%' - OR lower(COALESCE(test_type, '') || ' ' || COALESCE(test_id, '')) LIKE '%property%' - OR lower(COALESCE(test_type, '') || ' ' || COALESCE(test_id, '')) LIKE '%fuzz%' - THEN 'invariant' - ELSE 'stochastic' - END - WHERE is_mutation_verified = 1 - AND COALESCE(mutation_kind, '') = '' - "#, + include_str!("../../sql/storage/backfill_mutation_kind.sql"), [], )?; Ok(()) @@ -522,10 +191,7 @@ impl Storage { pub fn insert_metadata(&self, metadata: &CommitMetadata) -> Result<()> { self.conn.execute( - r#" - INSERT OR IGNORE INTO metadata (commit_hash, message, timestamp) - VALUES (?1, ?2, ?3) - "#, + include_str!("../../sql/storage/insert_metadata.sql"), params![metadata.hash, metadata.message, metadata.timestamp], )?; Ok(()) @@ -566,11 +232,7 @@ impl Storage { pub fn upsert_logical_unit(&self, unit: &LogicalUnit, created_at: i64) -> Result<()> { self.conn.execute( - r#" - INSERT INTO logical_units (id, name, type, original_path, created_at, start_line) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(id) DO NOTHING - "#, + include_str!("../../sql/storage/upsert_logical_unit.sql"), params![unit.id, unit.name, unit.kind.as_str(), unit.path, created_at, unit.start_line], )?; Ok(()) @@ -655,11 +317,10 @@ impl Storage { let active_ids = if let Some(ref hash) = target_commit { if let Ok(Some(state_json)) = self.load_engine_state(hash) { if let Ok(state) = serde_json::from_str::(&state_json) { - if let Some(previous) = state.get("previous").and_then(|p| p.as_object()) { - Some(previous.keys().cloned().collect::>()) - } else { - None - } + state + .get("previous") + .and_then(|previous| previous.as_object()) + .map(|previous| previous.keys().cloned().collect::>()) } else { None } @@ -671,29 +332,7 @@ impl Storage { }; let mut stmt = self.conn.prepare( - r#" - SELECT u.id, - COALESCE(( - SELECT latest.path - FROM events latest - WHERE latest.unit_id = u.id - ORDER BY latest.timestamp DESC, latest.id DESC - LIMIT 1 - ), u.original_path) AS path, - COALESCE(( - SELECT latest.start_line - FROM events latest - WHERE latest.unit_id = u.id - ORDER BY latest.timestamp DESC, latest.id DESC - LIMIT 1 - ), u.start_line) AS start_line - FROM logical_units u - WHERE u.name = ?1 - OR u.name LIKE '%.' || ?1 - OR u.name LIKE '%::' || ?1 - OR u.name LIKE '%#' || ?1 - LIMIT 100 - "#, + include_str!("../../sql/storage/find_definitions.sql"), )?; let rows = stmt.query_map(params![name], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, u32>(2)?)) @@ -745,12 +384,7 @@ impl Storage { pub fn insert_event(&self, event: &Event) -> Result<()> { self.conn.execute( - r#" - INSERT INTO events - (unit_id, commit_hash, event_type, path, name, start_line, end_line, - semantic_change, lines_added, lines_removed, timestamp) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - "#, + include_str!("../../sql/storage/insert_event.sql"), params![ event.unit_id, event.commit_hash, @@ -770,18 +404,7 @@ impl Storage { pub fn unit_ids_for_current_path(&self, path: &str) -> Result> { let mut stmt = self.conn.prepare( - r#" - SELECT u.id - FROM logical_units u - WHERE COALESCE(( - SELECT latest.path - FROM events latest - WHERE latest.unit_id = u.id - ORDER BY latest.timestamp DESC, latest.id DESC - LIMIT 1 - ), u.original_path) = ?1 - ORDER BY u.name, u.id - "#, + include_str!("../../sql/storage/unit_ids_for_current_path.sql"), )?; let rows = stmt.query_map(params![path], |row| row.get::<_, String>(0))?; Ok(rows.collect::, _>>()?) @@ -790,22 +413,7 @@ impl Storage { pub fn resolve_current_path(&self, path: &str) -> Result> { let normalized = path.trim_start_matches("./"); let mut stmt = self.conn.prepare( - r#" - WITH current_paths AS ( - SELECT DISTINCT COALESCE(( - SELECT latest.path - FROM events latest - WHERE latest.unit_id = u.id - ORDER BY latest.timestamp DESC, latest.id DESC - LIMIT 1 - ), u.original_path) AS current_path - FROM logical_units u - ) - SELECT current_path - FROM current_paths - WHERE current_path = ?1 - ORDER BY current_path - "#, + include_str!("../../sql/storage/resolve_current_path.sql"), )?; let exact = stmt .query_map(params![normalized], |row| row.get::<_, String>(0))? @@ -816,23 +424,7 @@ impl Storage { let suffix = format!("%/{normalized}"); let mut stmt = self.conn.prepare( - r#" - WITH current_paths AS ( - SELECT DISTINCT COALESCE(( - SELECT latest.path - FROM events latest - WHERE latest.unit_id = u.id - ORDER BY latest.timestamp DESC, latest.id DESC - LIMIT 1 - ), u.original_path) AS current_path - FROM logical_units u - ) - SELECT current_path - FROM current_paths - WHERE current_path LIKE ?1 - ORDER BY current_path - LIMIT 2 - "#, + include_str!("../../sql/storage/resolve_current_path_2.sql"), )?; let candidates = stmt .query_map(params![suffix], |row| row.get::<_, String>(0))? @@ -850,20 +442,7 @@ impl Storage { } let mut stmt = self.conn.prepare( - r#" - SELECT u.id - FROM logical_units u - WHERE u.name = ?2 - AND COALESCE(( - SELECT latest.path - FROM events latest - WHERE latest.unit_id = u.id - ORDER BY latest.timestamp DESC, latest.id DESC - LIMIT 1 - ), u.original_path) = ?1 - ORDER BY u.created_at DESC - LIMIT 1 - "#, + include_str!("../../sql/storage/resolve_unit_id.sql"), )?; let mut rows = stmt.query(params![path, name])?; Ok(rows.next()?.map(|row| row.get(0)).transpose()?) @@ -893,11 +472,7 @@ impl Storage { } self.conn.execute( - r#" - UPDATE quality_events - SET timestamp = ?2, new_value = ?3 - WHERE id = ?1 - "#, + include_str!("../../sql/storage/record_quality_metric.sql"), params![id, event.timestamp, merged_value], )?; return Ok(true); @@ -911,11 +486,7 @@ impl Storage { } self.conn.execute( - r#" - INSERT INTO quality_events - (unit_id, commit_hash, timestamp, metric_type, old_value, new_value) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - "#, + include_str!("../../sql/storage/record_quality_metric_2.sql"), params![ event.unit_id, event.commit_hash, @@ -939,13 +510,7 @@ impl Storage { metric: QualityMetric, ) -> Result> { let mut stmt = self.conn.prepare( - r#" - SELECT id, new_value - FROM quality_events - WHERE unit_id = ?1 AND commit_hash = ?2 AND metric_type = ?3 - ORDER BY id DESC - LIMIT 1 - "#, + include_str!("../../sql/storage/existing_quality_event.sql"), )?; Ok(stmt .query_row(params![unit_id, commit_hash, metric.as_str()], |row| { @@ -956,11 +521,7 @@ impl Storage { pub fn delete_coverage_for_commit(&self, commit_hash: &str) -> Result { let quality = self.conn.execute( - r#" - DELETE FROM quality_events - WHERE commit_hash = ?1 - AND metric_type IN ('LINE_COV', 'INTEGRATION_COV', 'MUTANT_COV', 'GATE_STATUS') - "#, + include_str!("../../sql/storage/delete_coverage_for_commit.sql"), params![commit_hash], )?; let lines = self.conn.execute( @@ -989,20 +550,13 @@ impl Storage { test_id: &str, ) -> Result { let mut stmt = self.conn.prepare( - r#" - SELECT DISTINCT unit_id - FROM test_exposure_events - WHERE commit_hash = ?1 AND test_type = ?2 AND test_id = ?3 - "#, + include_str!("../../sql/storage/delete_test_exposure_for_commit_test.sql"), )?; let unit_ids = stmt .query_map(params![commit_hash, test_type, test_id], |row| row.get::<_, String>(0))? .collect::, _>>()?; let deleted = self.conn.execute( - r#" - DELETE FROM test_exposure_events - WHERE commit_hash = ?1 AND test_type = ?2 AND test_id = ?3 - "#, + include_str!("../../sql/storage/delete_test_exposure_for_commit_test_2.sql"), params![commit_hash, test_type, test_id], )?; for unit_id in unit_ids { @@ -1025,18 +579,7 @@ impl Storage { pub fn insert_sarif_artifact(&self, artifact: &SarifArtifact) -> Result { self.conn.execute( - r#" - INSERT INTO sarif_artifacts - (source, tool_name, run_format, artifact_path, artifact_sha256, - commit_hash, timestamp, payload_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(source, commit_hash, artifact_path, artifact_sha256) DO UPDATE SET - tool_name = excluded.tool_name, - run_format = excluded.run_format, - timestamp = excluded.timestamp, - payload_json = excluded.payload_json, - ingested_at = strftime('%s', 'now') - "#, + include_str!("../../sql/storage/insert_sarif_artifact.sql"), params![ artifact.source, artifact.tool_name, @@ -1049,14 +592,7 @@ impl Storage { ], )?; let id = self.conn.query_row( - r#" - SELECT id - FROM sarif_artifacts - WHERE source = ?1 - AND commit_hash = ?2 - AND artifact_path = ?3 - AND artifact_sha256 = ?4 - "#, + include_str!("../../sql/storage/insert_sarif_artifact_2.sql"), params![ artifact.source, artifact.commit_hash, @@ -1070,15 +606,7 @@ impl Storage { pub fn insert_sarif_finding(&self, finding: &SarifFinding) -> Result { let inserted = self.conn.execute( - r#" - INSERT OR IGNORE INTO sarif_findings - (artifact_id, finding_key, source, tool_name, run_format, commit_hash, - timestamp, rule_id, level, message, path, start_line, start_column, - end_line, end_column, category, is_dark_arm, unit_id, fingerprint, - properties_json, raw_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, - ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21) - "#, + include_str!("../../sql/storage/insert_sarif_finding.sql"), params![ finding.artifact_id, finding.finding_key, @@ -1117,38 +645,7 @@ impl Storage { pub fn current_unit_spans_for_path(&self, path: &str) -> Result> { let mut stmt = self.conn.prepare( - r#" - WITH filtered_units AS ( - SELECT id FROM logical_units WHERE original_path = ?1 - UNION - SELECT unit_id AS id FROM events WHERE path = ?1 - ), - latest_events AS ( - SELECT * - FROM ( - SELECT e.*, - ROW_NUMBER() OVER ( - PARTITION BY e.unit_id - ORDER BY e.timestamp DESC, e.id DESC - ) AS rank - FROM events e - WHERE e.unit_id IN (SELECT id FROM filtered_units) - ) - WHERE rank = 1 - ), - current_units AS ( - SELECT u.id, - COALESCE(le.path, u.original_path) AS current_path, - COALESCE(le.start_line, 1) AS start_line, - COALESCE(le.end_line, le.start_line, 1) AS end_line - FROM logical_units u - LEFT JOIN latest_events le ON le.unit_id = u.id - WHERE u.id IN (SELECT id FROM filtered_units) - ) - SELECT id, current_path, start_line, end_line - FROM current_units - WHERE current_path = ?1 - "#, + include_str!("../../sql/storage/current_unit_spans_for_path.sql"), )?; let rows = stmt.query_map(params![path], |row| { Ok(CurrentUnitSpan { @@ -1164,31 +661,7 @@ impl Storage { pub fn current_unit_spans(&self) -> Result> { let mut stmt = self.conn.prepare( - r#" - WITH latest_events AS ( - SELECT * - FROM ( - SELECT e.*, - ROW_NUMBER() OVER ( - PARTITION BY e.unit_id - ORDER BY e.timestamp DESC, e.id DESC - ) AS rank - FROM events e - ) - WHERE rank = 1 - ), - current_units AS ( - SELECT u.id, - COALESCE(le.path, u.original_path) AS current_path, - COALESCE(le.start_line, 1) AS start_line, - COALESCE(le.end_line, le.start_line, 1) AS end_line - FROM logical_units u - LEFT JOIN latest_events le ON le.unit_id = u.id - ) - SELECT id, current_path, start_line, end_line - FROM current_units - WHERE current_path <> '' - "#, + include_str!("../../sql/storage/current_unit_spans.sql"), )?; let rows = stmt.query_map([], |row| { Ok(CurrentUnitSpan { @@ -1271,15 +744,7 @@ impl Storage { pub fn sarif_findings_for_path(&self, path: &str) -> Result> { let mut stmt = self.conn.prepare( - r#" - SELECT artifact_id, finding_key, source, tool_name, run_format, commit_hash, - timestamp, rule_id, level, message, path, start_line, start_column, - end_line, end_column, category, is_dark_arm, unit_id, fingerprint, - properties_json, raw_json - FROM sarif_findings - WHERE path = ?1 - ORDER BY start_line, source, tool_name, rule_id, message - "#, + include_str!("../../sql/storage/sarif_findings_for_path.sql"), )?; let rows = stmt.query_map(params![path], |row| { Ok(SarifFinding { @@ -1311,25 +776,29 @@ impl Storage { pub fn sarif_finding_counts_by_file(&self) -> Result> { let mut stmt = self.conn.prepare( - r#" - SELECT path, COUNT(*) AS findings - FROM sarif_findings - GROUP BY path - "#, + include_str!("../../sql/storage/sarif_finding_counts_by_file.sql"), )?; let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?; Ok(rows.collect::, _>>()?) } + pub fn sarif_lifecycle_summary(&self) -> Result { + self.conn.query_row( + include_str!("../../sql/storage/sarif_lifecycle_summary.sql"), + [], + |row| { + Ok(SarifLifecycleSummary { + new_findings: row.get(0)?, + resolved_findings: row.get(1)?, + persisted_findings: row.get(2)?, + }) + }, + ).map_err(Into::into) + } + fn refresh_current_quality_metrics(&self) -> Result<()> { self.conn.execute_batch( - r#" - UPDATE logical_units - SET current_line_cov = 0.0, - current_integration_cov = 0.0, - current_mutant_cov = 0.0, - is_hard_gated = 0; - "#, + include_str!("../../sql/storage/refresh_current_quality_metrics.sql"), )?; for (metric, column) in [ (QualityMetric::LineCoverage, "current_line_cov"), @@ -1339,17 +808,8 @@ impl Storage { ] { self.conn.execute( &format!( - r#" - UPDATE logical_units - SET {column} = COALESCE(( - SELECT q.new_value - FROM quality_events q - WHERE q.unit_id = logical_units.id - AND q.metric_type = ?1 - ORDER BY q.timestamp DESC, q.id DESC - LIMIT 1 - ), 0.0) - "# + include_str!("../../sql/storage/refresh_current_quality_metrics_2.sql"), + column = column ), params![metric.as_str()], )?; @@ -1365,9 +825,10 @@ impl Storage { line: u32, hits: u32, ) -> Result { - self.record_coverage_line_with_source(commit_hash, timestamp, path, line, hits, "coverage") + self.record_coverage_line_with_source(commit_hash, timestamp, path, line, hits, false, "coverage") } + #[allow(clippy::too_many_arguments)] pub fn record_coverage_line_with_source( &self, commit_hash: &str, @@ -1375,20 +836,38 @@ impl Storage { path: &str, line: u32, hits: u32, + is_partial: bool, + source: &str, + ) -> Result { + self.record_coverage_line_with_details( + commit_hash, timestamp, path, line, hits, is_partial, None, source, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn record_coverage_line_with_details( + &self, + commit_hash: &str, + timestamp: i64, + path: &str, + line: u32, + hits: u32, + is_partial: bool, + coverage_percent: Option, source: &str, ) -> Result { let changed = self.conn.execute( - r#" - INSERT INTO coverage_line_events - (commit_hash, timestamp, path, line, hits, source) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(commit_hash, path, line, source) DO UPDATE SET - timestamp = MAX(coverage_line_events.timestamp, excluded.timestamp), - hits = MAX(coverage_line_events.hits, excluded.hits) - WHERE excluded.timestamp > coverage_line_events.timestamp - OR excluded.hits > coverage_line_events.hits - "#, - params![commit_hash, timestamp, path, line, hits, source], + include_str!("../../sql/storage/record_coverage_line_with_details.sql"), + params![ + commit_hash, + timestamp, + path, + line, + hits, + if is_partial { 1 } else { 0 }, + coverage_percent, + source + ], )?; Ok(changed > 0) } @@ -1412,19 +891,7 @@ impl Storage { pub fn insert_crash_event(&self, event: &CrashEvent) -> Result { let mut stmt = self.conn.prepare( - r#" - SELECT id, timestamp, is_verified - FROM crash_events - WHERE unit_id = ?1 - AND commit_hash = ?2 - AND error_class = ?3 - AND provider_id = ?4 - AND path = ?5 - AND line = ?6 - AND function = ?7 - ORDER BY id DESC - LIMIT 1 - "#, + include_str!("../../sql/storage/insert_crash_event.sql"), )?; let existing = stmt .query_row( @@ -1459,12 +926,7 @@ impl Storage { } self.conn.execute( - r#" - INSERT INTO crash_events - (unit_id, commit_hash, timestamp, error_class, provider_id, - is_verified, path, line, function) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) - "#, + include_str!("../../sql/storage/insert_crash_event_2.sql"), params![ event.unit_id, event.commit_hash, @@ -1489,33 +951,7 @@ impl Storage { pub fn insert_test_exposure_event(&self, event: &TestExposureEvent) -> Result { let changed = self.conn.execute( - r#" - INSERT INTO test_exposure_events - (unit_id, commit_hash, timestamp, path, function, line, branch_id, - test_id, test_type, mutation_status, mutation_kind, is_mutation_verified, - is_mutation_killed, is_verified, payload_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, COALESCE(?11, ''), ?12, ?13, ?14, ?15) - ON CONFLICT DO UPDATE SET - timestamp = MAX(test_exposure_events.timestamp, excluded.timestamp), - function = COALESCE(excluded.function, test_exposure_events.function), - mutation_status = COALESCE(excluded.mutation_status, test_exposure_events.mutation_status), - mutation_kind = CASE - WHEN lower(COALESCE(test_exposure_events.mutation_kind, '')) IN ('invariant', 'contract') THEN test_exposure_events.mutation_kind - WHEN lower(COALESCE(excluded.mutation_kind, '')) IN ('invariant', 'contract') THEN excluded.mutation_kind - WHEN COALESCE(excluded.mutation_kind, '') <> '' THEN excluded.mutation_kind - ELSE test_exposure_events.mutation_kind - END, - is_mutation_verified = MAX(test_exposure_events.is_mutation_verified, excluded.is_mutation_verified), - is_mutation_killed = MAX(test_exposure_events.is_mutation_killed, excluded.is_mutation_killed), - is_verified = MAX(test_exposure_events.is_verified, excluded.is_verified), - payload_json = excluded.payload_json - WHERE excluded.timestamp > test_exposure_events.timestamp - OR excluded.is_mutation_verified > test_exposure_events.is_mutation_verified - OR excluded.is_mutation_killed > test_exposure_events.is_mutation_killed - OR excluded.is_verified > test_exposure_events.is_verified - OR COALESCE(excluded.mutation_kind, '') <> COALESCE(test_exposure_events.mutation_kind, '') - OR excluded.payload_json <> test_exposure_events.payload_json - "#, + include_str!("../../sql/storage/insert_test_exposure_event.sql"), params![ event.unit_id, event.commit_hash, @@ -1549,12 +985,7 @@ impl Storage { pub fn insert_hazard_event(&self, event: &HazardEvent) -> Result<()> { self.conn.execute( - r#" - INSERT INTO unit_hazards - (unit_id, language, hazard_type, required_evidence, path, line, - symbol, source, detected_at_hash, is_active, payload_json) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) - "#, + include_str!("../../sql/storage/insert_hazard_event.sql"), params![ event.unit_id, event.language, @@ -1574,13 +1005,7 @@ impl Storage { fn refresh_test_exposure_summary(&self, unit_id: &str) -> Result<()> { let mut latest_stmt = self.conn.prepare( - r#" - SELECT commit_hash, timestamp - FROM test_exposure_events - WHERE unit_id = ?1 - ORDER BY timestamp DESC, id DESC - LIMIT 1 - "#, + include_str!("../../sql/storage/refresh_test_exposure_summary.sql"), )?; let mut latest_rows = latest_stmt.query(params![unit_id])?; let Some(latest_row) = latest_rows.next()? else { @@ -1590,39 +1015,22 @@ impl Storage { let latest_timestamp: i64 = latest_row.get(1)?; let distinct_tests: i64 = self.conn.query_row( - r#" - SELECT COUNT(DISTINCT test_id) - FROM test_exposure_events - WHERE unit_id = ?1 AND commit_hash = ?2 - "#, + include_str!("../../sql/storage/refresh_test_exposure_summary_2.sql"), params![unit_id, latest_commit], |row| row.get(0), )?; let mutant_verified: i64 = self.conn.query_row( - r#" - SELECT COUNT(DISTINCT test_id) - FROM test_exposure_events - WHERE unit_id = ?1 AND commit_hash = ?2 AND is_mutation_verified = 1 - "#, + include_str!("../../sql/storage/refresh_test_exposure_summary_3.sql"), params![unit_id, latest_commit], |row| row.get(0), )?; let mutant_killed: i64 = self.conn.query_row( - r#" - SELECT COUNT(DISTINCT test_id) - FROM test_exposure_events - WHERE unit_id = ?1 AND commit_hash = ?2 AND is_mutation_killed = 1 - "#, + include_str!("../../sql/storage/refresh_test_exposure_summary_4.sql"), params![unit_id, latest_commit], |row| row.get(0), )?; let mut type_stmt = self.conn.prepare( - r#" - SELECT DISTINCT test_type - FROM test_exposure_events - WHERE unit_id = ?1 AND commit_hash = ?2 AND test_type <> '' - ORDER BY test_type - "#, + include_str!("../../sql/storage/refresh_test_exposure_summary_5.sql"), )?; let type_rows = type_stmt.query_map(params![unit_id, latest_commit], |row| { row.get::<_, String>(0) @@ -1632,15 +1040,7 @@ impl Storage { .join(","); self.conn.execute( - r#" - UPDATE logical_units - SET current_distinct_tests = ?2, - current_test_types = ?3, - current_mutant_verified_tests = ?4, - current_mutant_killed_tests = ?5, - last_test_exposure_at = ?6 - WHERE id = ?1 - "#, + include_str!("../../sql/storage/refresh_test_exposure_summary_6.sql"), params![ unit_id, distinct_tests, @@ -1661,402 +1061,7 @@ impl Storage { pub fn refresh_ui_summaries(&self) -> Result<()> { self.begin_transaction()?; let result = self.conn.execute_batch( - r#" - DELETE FROM ui_file_summaries; - DELETE FROM ui_warning_units; - - WITH latest_events AS ( - SELECT unit_id, path - FROM ( - SELECT unit_id, path, - ROW_NUMBER() OVER ( - PARTITION BY unit_id - ORDER BY timestamp DESC, id DESC - ) AS rank - FROM events - ) - WHERE rank = 1 - ), - current_units AS ( - SELECT u.id, - COALESCE(le.path, u.original_path) AS current_path, - u.current_line_cov, - u.current_mutant_cov, - u.current_distinct_tests, - u.current_mutant_killed_tests - FROM logical_units u - LEFT JOIN latest_events le ON le.unit_id = u.id - ), - unit_file AS ( - SELECT current_path AS path, - COUNT(DISTINCT id) AS units, - COALESCE(SUM(current_distinct_tests), 0) AS distinct_tests, - COALESCE(SUM(current_mutant_killed_tests), 0) AS mutant_killed_tests, - COALESCE(AVG(current_line_cov), 0.0) AS fallback_line_coverage, - COALESCE(AVG(current_mutant_cov), 0.0) AS mutant_coverage - FROM current_units - WHERE current_path <> '' - GROUP BY current_path - ), - latest_source_lines AS ( - SELECT path, line, source, hits - FROM ( - SELECT path, line, source, hits, - ROW_NUMBER() OVER ( - PARTITION BY path, line, source - ORDER BY timestamp DESC, id DESC - ) AS rank - FROM coverage_line_events - ) - WHERE rank = 1 - ), - latest_lines AS ( - SELECT path, line, MAX(hits) AS hits - FROM latest_source_lines - GROUP BY path, line - ), - line_file AS ( - SELECT path, - COUNT(*) AS tracked_lines, - SUM(CASE WHEN hits > 0 THEN 1 ELSE 0 END) AS covered_lines - FROM latest_lines - GROUP BY path - ), - ranked_exposure AS ( - SELECT path, line, branch_id, test_id, test_type, is_verified, - is_mutation_verified, is_mutation_killed, mutation_kind, - ROW_NUMBER() OVER ( - PARTITION BY path, line, COALESCE(branch_id, ''), test_id, test_type - ORDER BY timestamp DESC, id DESC - ) AS rank - FROM test_exposure_events - WHERE line IS NOT NULL - ), - latest_exposure AS ( - SELECT * - FROM ranked_exposure - WHERE rank = 1 - ), - line_exposure AS ( - SELECT e.path, - e.line, - l.hits, - COUNT(DISTINCT CASE WHEN e.is_verified = 1 THEN e.test_type END) AS verified_test_types, - MAX(CASE WHEN e.is_verified = 1 AND e.is_mutation_verified = 1 THEN 1 ELSE 0 END) AS mutant_verified, - MAX(CASE WHEN e.is_verified = 1 AND e.is_mutation_killed = 1 THEN 1 ELSE 0 END) AS mutant_killed, - MAX(CASE - WHEN e.is_verified = 1 - AND e.is_mutation_verified = 1 - AND lower(COALESCE(e.mutation_kind, '')) = 'stochastic' - THEN 1 ELSE 0 - END) AS stochastic_mutant_verified, - MAX(CASE - WHEN e.is_verified = 1 - AND e.is_mutation_killed = 1 - AND lower(COALESCE(e.mutation_kind, '')) = 'stochastic' - THEN 1 ELSE 0 - END) AS stochastic_mutant_killed, - MAX(CASE - WHEN e.is_verified = 1 - AND e.is_mutation_killed = 1 - AND lower(COALESCE(e.mutation_kind, '')) IN ('invariant', 'contract') - THEN 1 ELSE 0 - END) AS invariant_mutant_killed, - MAX(CASE - WHEN e.is_verified = 1 - AND e.is_mutation_verified = 1 - AND lower(COALESCE(e.mutation_kind, '')) IN ('invariant', 'contract') - THEN 1 ELSE 0 - END) AS invariant_mutant_verified - FROM latest_exposure e - JOIN latest_lines l - ON l.path = e.path - AND l.line = e.line - AND l.hits > 0 - GROUP BY e.path, e.line - ), - exposure_file AS ( - SELECT path, - SUM(mutant_verified) AS mutant_verified_covered_lines, - SUM(mutant_killed) AS mutant_killed_covered_lines, - SUM(stochastic_mutant_verified) AS stochastic_mutant_verified_covered_lines, - SUM(stochastic_mutant_killed) AS stochastic_mutant_killed_covered_lines, - SUM(invariant_mutant_verified) AS invariant_mutant_verified_covered_lines, - SUM(invariant_mutant_killed) AS invariant_mutant_killed_covered_lines, - SUM(CASE WHEN verified_test_types >= 2 OR hits > 1 THEN 1 ELSE 0 END) AS multi_type_covered_lines - FROM line_exposure - GROUP BY path - ), - active_hazards AS ( - SELECT * - FROM unit_hazards - WHERE is_active = 1 - ), - hazard_ranked_exposure AS ( - SELECT t.unit_id, - t.path, - t.line, - t.branch_id, - t.test_id, - t.test_type, - t.is_verified, - t.is_mutation_killed, - t.mutation_kind, - ROW_NUMBER() OVER ( - PARTITION BY t.path, t.line, COALESCE(t.branch_id, ''), t.test_id, t.test_type - ORDER BY t.timestamp DESC, t.id DESC - ) AS rank - FROM test_exposure_events t - JOIN active_hazards h - ON h.unit_id = t.unit_id - AND h.path = t.path - AND h.line = t.line - WHERE t.line IS NOT NULL - ), - hazard_latest_exposure AS ( - SELECT * - FROM hazard_ranked_exposure - WHERE rank = 1 - ), - hazard_evidence AS ( - SELECT unit_id, - path, - line, - lower(test_type) AS test_type, - MAX(CASE WHEN is_verified = 1 THEN 1 ELSE 0 END) AS has_evidence, - MAX(CASE - WHEN is_verified = 1 - AND is_mutation_killed = 1 - AND lower(COALESCE(mutation_kind, '')) IN ('invariant', 'contract') - THEN 1 ELSE 0 - END) AS has_invariant_mutation - FROM hazard_latest_exposure - GROUP BY unit_id, path, line, lower(test_type) - ), - hazard_rows AS ( - SELECT h.id, - h.path, - CASE - WHEN MAX(CASE - WHEN (e.test_type = lower(h.required_evidence) - OR e.test_type LIKE '%' || lower(h.required_evidence) || '%') - AND e.has_evidence = 1 - THEN 1 ELSE 0 - END) = 1 - OR MAX(CASE - WHEN ls.hits > 0 - AND (lower(ls.source) = lower(h.required_evidence) - OR lower(ls.source) LIKE '%' || lower(h.required_evidence) || '%') - THEN 1 ELSE 0 - END) = 1 - THEN 1 ELSE 0 - END AS evidence_present, - CASE - WHEN MAX(CASE WHEN l.hits > 0 THEN 1 ELSE 0 END) = 1 - OR MAX(CASE - WHEN (e.test_type = lower(h.required_evidence) - OR e.test_type LIKE '%' || lower(h.required_evidence) || '%') - AND e.has_evidence = 1 - THEN 1 ELSE 0 - END) = 1 - OR MAX(CASE - WHEN ls.hits > 0 - AND (lower(ls.source) = lower(h.required_evidence) - OR lower(ls.source) LIKE '%' || lower(h.required_evidence) || '%') - THEN 1 ELSE 0 - END) = 1 - THEN 1 ELSE 0 - END AS verified - FROM active_hazards h - LEFT JOIN hazard_evidence e - ON e.unit_id = h.unit_id - AND e.path = h.path - AND e.line = h.line - LEFT JOIN latest_lines l - ON l.path = h.path - AND l.line = h.line - LEFT JOIN latest_source_lines ls - ON ls.path = h.path - AND ls.line = h.line - GROUP BY h.id, h.path - ), - hazard_file AS ( - SELECT path, - COUNT(*) AS hazards, - SUM(evidence_present) AS evidence_covered_hazards, - SUM(verified) AS covered_hazards - FROM hazard_rows - GROUP BY path - ), - paths AS ( - SELECT path FROM unit_file - UNION - SELECT path FROM line_file - UNION - SELECT path FROM exposure_file - UNION - SELECT path FROM hazard_file - ) - INSERT INTO ui_file_summaries ( - path, - units, - hazards, - evidence_covered_hazards, - covered_hazards, - distinct_tests, - mutant_killed_tests, - tracked_lines, - covered_lines, - line_coverage, - mutant_coverage, - mutant_verified_covered_lines, - mutant_killed_covered_lines, - stochastic_mutant_verified_covered_lines, - stochastic_mutant_killed_covered_lines, - invariant_mutant_verified_covered_lines, - invariant_mutant_killed_covered_lines, - multi_type_covered_lines - ) - SELECT p.path, - COALESCE(uf.units, 0), - COALESCE(hf.hazards, 0), - COALESCE(hf.evidence_covered_hazards, 0), - COALESCE(hf.covered_hazards, 0), - COALESCE(uf.distinct_tests, 0), - COALESCE(uf.mutant_killed_tests, 0), - COALESCE(lf.tracked_lines, 0), - COALESCE(lf.covered_lines, 0), - CASE - WHEN COALESCE(lf.tracked_lines, 0) > 0 - THEN 100.0 * COALESCE(lf.covered_lines, 0) / lf.tracked_lines - ELSE COALESCE(uf.fallback_line_coverage, 0.0) - END, - COALESCE(uf.mutant_coverage, 0.0), - COALESCE(ef.mutant_verified_covered_lines, 0), - COALESCE(ef.mutant_killed_covered_lines, 0), - COALESCE(ef.stochastic_mutant_verified_covered_lines, 0), - COALESCE(ef.stochastic_mutant_killed_covered_lines, 0), - COALESCE(ef.invariant_mutant_verified_covered_lines, 0), - COALESCE(ef.invariant_mutant_killed_covered_lines, 0), - COALESCE(ef.multi_type_covered_lines, 0) - FROM paths p - LEFT JOIN unit_file uf ON uf.path = p.path - LEFT JOIN line_file lf ON lf.path = p.path - LEFT JOIN exposure_file ef ON ef.path = p.path - LEFT JOIN hazard_file hf ON hf.path = p.path - WHERE p.path <> ''; - - WITH latest_events AS ( - SELECT unit_id, path - FROM ( - SELECT unit_id, path, - ROW_NUMBER() OVER ( - PARTITION BY unit_id - ORDER BY timestamp DESC, id DESC - ) AS rank - FROM events - ) - WHERE rank = 1 - ), - current_units AS ( - SELECT u.id, - COALESCE(le.path, u.original_path) AS current_path, - u.current_distinct_tests, - u.current_mutant_verified_tests, - u.last_test_exposure_at - FROM logical_units u - LEFT JOIN latest_events le ON le.unit_id = u.id - ), - db_clock AS ( - SELECT COALESCE(MAX(timestamp), 0) AS observed_at - FROM ( - SELECT timestamp FROM metadata - UNION ALL SELECT timestamp FROM events - UNION ALL SELECT timestamp FROM quality_events - UNION ALL SELECT timestamp FROM crash_events - UNION ALL SELECT timestamp FROM test_exposure_events - ) - ), - mutant_runs AS ( - SELECT unit_id, MAX(timestamp) AS last_mutant_run_at - FROM test_exposure_events - WHERE is_mutation_verified = 1 OR is_mutation_killed = 1 - GROUP BY unit_id - ), - event_counts AS ( - SELECT cu.id, - SUM(CASE - WHEN cu.last_test_exposure_at > 0 - AND e.semantic_change = 1 - AND e.event_type IN ('FIX', 'CHANGE') - AND e.timestamp > cu.last_test_exposure_at - THEN 1 ELSE 0 - END) AS changes_after_test_exposure, - SUM(CASE - WHEN COALESCE(m.last_mutant_run_at, 0) > 0 - AND e.semantic_change = 1 - AND e.event_type IN ('FIX', 'CHANGE') - AND e.timestamp > m.last_mutant_run_at - THEN 1 ELSE 0 - END) AS semantic_changes_after_mutant_run - FROM current_units cu - LEFT JOIN mutant_runs m ON m.unit_id = cu.id - LEFT JOIN events e ON e.unit_id = cu.id - GROUP BY cu.id - ), - reopened AS ( - SELECT c.unit_id, COUNT(DISTINCT c.id) AS reopened_count - FROM crash_events c - WHERE EXISTS ( - SELECT 1 - FROM events fix - WHERE fix.unit_id = c.unit_id - AND fix.event_type = 'FIX' - AND fix.semantic_change = 1 - AND fix.path = c.path - AND c.line BETWEEN fix.start_line AND fix.end_line - AND c.timestamp > fix.timestamp - ) - GROUP BY c.unit_id - ) - INSERT INTO ui_warning_units ( - unit_id, - current_path, - current_distinct_tests, - current_mutant_verified_tests, - last_test_exposure_at, - last_mutant_run_at, - changes_after_test_exposure, - semantic_changes_after_mutant_run, - verification_stale_seconds, - reopened_count - ) - SELECT cu.id, - cu.current_path, - cu.current_distinct_tests, - cu.current_mutant_verified_tests, - cu.last_test_exposure_at, - COALESCE(m.last_mutant_run_at, 0), - COALESCE(ec.changes_after_test_exposure, 0), - COALESCE(ec.semantic_changes_after_mutant_run, 0), - CASE - WHEN COALESCE(m.last_mutant_run_at, 0) > 0 - AND clock.observed_at > m.last_mutant_run_at - THEN clock.observed_at - m.last_mutant_run_at - ELSE 0 - END, - COALESCE(r.reopened_count, 0) - FROM current_units cu - LEFT JOIN mutant_runs m ON m.unit_id = cu.id - LEFT JOIN event_counts ec ON ec.id = cu.id - LEFT JOIN reopened r ON r.unit_id = cu.id - CROSS JOIN db_clock clock - WHERE cu.current_path <> ''; - - INSERT INTO ui_refresh_metadata (key, value) - VALUES ('refreshed_at', strftime('%s', 'now')) - ON CONFLICT(key) DO UPDATE SET value = excluded.value; - "#, + include_str!("../../sql/storage/refresh_ui_summaries.sql"), ); if let Err(error) = result { let _ = self.rollback_transaction(); @@ -2344,108 +1349,14 @@ impl Storage { fn configure_connection(conn: &Connection) -> Result<()> { conn.execute_batch( - r#" - PRAGMA foreign_keys = ON; - PRAGMA synchronous = NORMAL; - "#, + include_str!("../../sql/storage/configure_connection.sql"), )?; Ok(()) } fn apply_decayed_risk(conn: &Connection, summaries: &mut HashMap) -> Result<()> { let mut stmt = conn.prepare( - r#" - WITH fix_commit_raw AS ( - SELECT commit_hash, - COUNT(DISTINCT CASE - WHEN NOT ( - path LIKE 'spec/%' - OR path LIKE 'test/%' - OR path LIKE 'tests/%' - OR path LIKE 'transpile-tests/%' - OR path LIKE 'tools/fuzz/%' - OR path LIKE '%/spec/%' - OR path LIKE '%/test/%' - OR path LIKE '%_spec.%' - OR path LIKE '%_test.%' - ) - THEN unit_id END) AS code_units, - COUNT(DISTINCT CASE - WHEN NOT ( - path LIKE 'spec/%' - OR path LIKE 'test/%' - OR path LIKE 'tests/%' - OR path LIKE 'transpile-tests/%' - OR path LIKE 'tools/fuzz/%' - OR path LIKE '%/spec/%' - OR path LIKE '%/test/%' - OR path LIKE '%_spec.%' - OR path LIKE '%_test.%' - ) - THEN path END) AS code_files, - COALESCE(SUM(CASE - WHEN NOT ( - path LIKE 'spec/%' - OR path LIKE 'test/%' - OR path LIKE 'tests/%' - OR path LIKE 'transpile-tests/%' - OR path LIKE 'tools/fuzz/%' - OR path LIKE '%/spec/%' - OR path LIKE '%/test/%' - OR path LIKE '%_spec.%' - OR path LIKE '%_test.%' - ) - THEN ABS(lines_added) + ABS(lines_removed) ELSE 0 END), 0) AS code_lines - FROM events - WHERE event_type = 'FIX' - AND semantic_change = 1 - GROUP BY commit_hash - ), - fix_commit_profiles AS ( - SELECT commit_hash, - CASE - WHEN code_units BETWEEN 1 AND 3 - AND code_files BETWEEN 1 AND 3 - AND code_lines <= 80 - THEN 1.0 - WHEN code_units BETWEEN 1 AND 8 - AND code_files BETWEEN 1 AND 5 - AND code_lines <= 200 - THEN 0.65 - WHEN code_units BETWEEN 1 AND 20 - AND code_files BETWEEN 1 AND 10 - AND code_lines <= 500 - THEN 0.30 - ELSE 0.10 - END AS target_factor - FROM fix_commit_raw - ) - SELECT e.unit_id, - e.event_type, - e.timestamp, - CASE WHEN e.event_type = 'FIX' - THEN COALESCE(fp.target_factor, 0.10) - ELSE 1.0 - END AS target_factor, - CASE - WHEN e.event_type = 'FIX' - AND COALESCE(fp.target_factor, 0.10) >= 0.65 - AND EXISTS ( - SELECT 1 - FROM test_exposure_events t - WHERE t.unit_id = e.unit_id - AND t.timestamp > e.timestamp - AND t.is_mutation_killed = 1 - LIMIT 1 - ) - THEN 0.25 - ELSE 1.0 - END AS mutation_hardening_factor - FROM events e - LEFT JOIN fix_commit_profiles fp ON fp.commit_hash = e.commit_hash - WHERE e.semantic_change = 1 - AND e.event_type IN ('FIX', 'CHANGE') - "#, + include_str!("../../sql/storage/apply_decayed_risk.sql"), )?; let rows = stmt.query_map([], |row| { Ok(( @@ -2514,6 +1425,123 @@ fn checked_table(table: &str) -> Result<&str> { mod tests { use super::*; use crate::model::{EventType, UnitKind}; + use std::fs; + + fn collected_sql_files(root: &Path) -> Vec { + let mut files = Vec::new(); + for entry in fs::read_dir(root).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + files.extend(collected_sql_files(&path)); + } else if path.extension().and_then(|value| value.to_str()) == Some("sql") { + files.push(path); + } + } + files.sort(); + files + } + + #[test] + fn extracted_storage_and_ui_queries_prepare_against_the_real_schema() { + let storage = Storage::open_memory().unwrap(); + let sql_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("sql"); + let files = [sql_root.join("storage"), sql_root.join("ui/runtime")] + .into_iter() + .flat_map(|root| collected_sql_files(&root)) + .collect::>(); + assert!(files.len() >= 70); + for path in files { + let sql = fs::read_to_string(&path) + .unwrap() + .replace("{column}", "current_line_cov"); + storage + .connection() + .prepare(&sql) + .unwrap_or_else(|error| panic!("{} did not prepare: {error}", path.display())); + } + } + + #[test] + fn partial_line_percentage_is_not_masked_by_a_full_coverage_source() { + let storage = Storage::open_memory().unwrap(); + storage + .record_coverage_line_with_details( + "old", + 10, + "sql/query.sql", + 3, + 1, + false, + Some(100.0), + "coverage:legacy", + ) + .unwrap(); + storage + .record_coverage_line_with_details( + "new", + 20, + "sql/query.sql", + 3, + 1, + true, + Some(200.0 / 3.0), + "coverage:sql-cov", + ) + .unwrap(); + storage.refresh_ui_summaries().unwrap(); + let (partial, coverage) = storage + .connection() + .query_row( + "SELECT partial_lines, line_coverage FROM ui_file_summaries WHERE path = ?1", + ["sql/query.sql"], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?)), + ) + .unwrap(); + assert_eq!(partial, 1); + assert!((coverage - 200.0 / 3.0).abs() < 0.000_001); + } + + #[test] + fn migrates_partial_coverage_and_merges_partial_observations() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("legacy.db"); + let legacy = Connection::open(&path).unwrap(); + legacy + .execute_batch( + r#" + CREATE TABLE coverage_line_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + commit_hash TEXT NOT NULL, + timestamp INTEGER NOT NULL, + path TEXT NOT NULL, + line INTEGER NOT NULL, + hits INTEGER NOT NULL, + source TEXT NOT NULL DEFAULT 'coverage', + UNIQUE(commit_hash, path, line, source) + ); + "#, + ) + .unwrap(); + drop(legacy); + + let storage = Storage::open(&path).unwrap(); + assert!(storage + .record_coverage_line_with_source("abc", 10, "src/a.rb", 3, 1, false, "unit") + .unwrap()); + assert!(storage + .record_coverage_line_with_source("abc", 10, "src/a.rb", 3, 1, true, "unit") + .unwrap()); + + let is_partial: i64 = storage + .connection() + .query_row( + "SELECT is_partial FROM coverage_line_events WHERE path = 'src/a.rb'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(is_partial, 1); + } #[test] fn creates_schema_and_records_events() { diff --git a/gems/lineage/src/lib.rs b/gems/lineage/src/lib.rs index 7ecb4d333..11977ef0f 100644 --- a/gems/lineage/src/lib.rs +++ b/gems/lineage/src/lib.rs @@ -5,6 +5,8 @@ #[path = "db/engine.rs"] pub mod engine; +#[path = "db/architecture.rs"] +pub mod architecture; #[path = "db/extract.rs"] pub mod extract; #[path = "db/git.rs"] @@ -33,6 +35,7 @@ pub mod ui; pub mod vcs; pub use engine::{EngineStats, LineageEngine}; +pub use architecture::{architecture_search, ingest_architecture_json, node_neighborhood, owner_inventory, state_access, ArchitectureIngestStats}; pub use extract::{BoundaryExtractor, HeuristicExtractor, SourceFilter}; pub use git::GitProvider; pub use hazard::{ingest_hazards, HazardIngestStats}; diff --git a/gems/lineage/src/main.rs b/gems/lineage/src/main.rs index 5cff36ab0..29201f186 100644 --- a/gems/lineage/src/main.rs +++ b/gems/lineage/src/main.rs @@ -5,7 +5,7 @@ use lineage::{ ingest_mutant_facts_json, ingest_sarif_paths, ingest_stack_traces, ingest_test_exposure_json, parse_coverage_input, resolve_coverage_record_paths, serve_lsp, serve_ui_with_overlays, CoverageIngestOptions, GitProvider, HeuristicExtractor, LineageEngine, - RepoPathNormalizer, SentryProvider, Storage, + RepoPathNormalizer, SentryProvider, Storage, ingest_architecture_json, }; use std::fs; use std::path::PathBuf; @@ -25,6 +25,13 @@ enum Command { #[arg(long, default_value = "lineage.db")] db: PathBuf, }, + /// Ingest a versioned Espalier architecture graph artifact. + IngestArchitecture { + #[arg(long, default_value = "lineage.db")] + db: PathBuf, + #[arg(long)] + input: PathBuf, + }, /// Build lineage data for a Git repository. Build { #[arg(long, default_value = ".")] @@ -177,6 +184,15 @@ fn main() -> Result<()> { Storage::open(&db)?; println!("initialized {}", db.display()); } + Command::IngestArchitecture { db, input } => { + let storage = Storage::open(&db)?; + let payload = fs::read_to_string(&input)?; + let stats = ingest_architecture_json(&storage, &payload)?; + println!( + "ingested architecture: artifacts={} nodes={} edges={} spans={} reconciled_units={}", + stats.artifacts, stats.nodes, stats.edges, stats.spans, stats.reconciled_units + ); + } Command::Build { repo, db, diff --git a/gems/lineage/src/ui/assets/app.css b/gems/lineage/src/ui/assets/app.css index 6892a3f5f..03f03f8e5 100644 --- a/gems/lineage/src/ui/assets/app.css +++ b/gems/lineage/src/ui/assets/app.css @@ -11,7 +11,53 @@ --dark-arm: #374151; --dark-arm-bg: rgba(31, 41, 55, 0.22); --link: #1d4ed8; - } +} + +/* Focused architecture view */ +.outline-architecture { margin-left: auto; min-width: 1.35rem; height: 1.35rem; display: inline-grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; font-size: .68rem; font-weight: 800; text-decoration: none; color: var(--muted); } +.outline-architecture.architecture-band-amber { border-color: #d97706; color: #92400e; } +.outline-architecture.architecture-band-orange { border-color: #ea580c; color: #c2410c; } +.outline-architecture.architecture-band-red { border-color: #dc2626; color: #b91c1c; } +.architecture-page { margin: 0; background: var(--bg); color: var(--text); } +.architecture-shell { max-width: 1500px; margin: 0 auto; padding: 1rem; } +.architecture-header { display: flex; justify-content: space-between; gap: 2rem; align-items: center; padding: 1rem 1.25rem; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; } +.architecture-header h1 { margin: .25rem 0 0; font-size: 1.45rem; } +.architecture-header p, .architecture-header small { display: block; margin: .25rem 0 0; color: var(--muted); } +.architecture-lenses { display: flex; gap: .35rem; margin: .75rem 0; } +.architecture-stale { margin: .75rem 0; padding: .7rem 1rem; border: 1px solid #d97706; border-radius: 8px; background: #fffbeb; color: #92400e; } +.architecture-lenses a { padding: .45rem .75rem; border: 1px solid var(--line); border-radius: 999px; text-decoration: none; color: var(--text); background: var(--panel); } +.architecture-lenses a.active { color: white; background: #1d4ed8; border-color: #1d4ed8; } +.architecture-workspace { display: grid; grid-template-columns: minmax(220px, 300px) minmax(0, 1fr); gap: .75rem; align-items: start; } +.architecture-members, .architecture-focus { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; } +.architecture-members { padding: .75rem; max-height: calc(100vh - 11rem); overflow: auto; position: sticky; top: .5rem; } +.architecture-members label { display: grid; gap: .4rem; font-weight: 700; margin-bottom: .6rem; } +.architecture-members input { width: 100%; padding: .55rem; border: 1px solid var(--line); border-radius: 6px; } +.architecture-member { display: flex; justify-content: space-between; gap: .5rem; align-items: center; padding: .55rem; border-left: 4px solid transparent; border-bottom: 1px solid var(--line); color: var(--text); text-decoration: none; } +.architecture-member span { display: grid; min-width: 0; overflow-wrap: anywhere; } +.architecture-member small { color: var(--muted); text-transform: uppercase; font-size: .62rem; } +.architecture-member.selected { background: color-mix(in srgb, #2563eb 10%, transparent); } +.architecture-member.architecture-band-amber { border-left-color: #d97706; } +.architecture-member.architecture-band-orange { border-left-color: #ea580c; } +.architecture-member.architecture-band-red { border-left-color: #dc2626; } +.architecture-focus { min-width: 0; overflow: hidden; } +.architecture-graph-toolbar { display: flex; justify-content: space-between; align-items: center; padding: .65rem .85rem; border-bottom: 1px solid var(--line); } +.architecture-graph-viewport { overflow: auto; min-height: 330px; background: color-mix(in srgb, var(--panel) 92%, #2563eb); } +.architecture-graph { display: block; width: 100%; min-width: 700px; min-height: 330px; } +.architecture-edge { fill: none; stroke: #64748b; stroke-width: 1.5; } +.architecture-edge.partial { stroke-dasharray: 7 5; } +.architecture-node rect { fill: var(--panel); stroke: #64748b; stroke-width: 1.5; } +.architecture-node.selected rect { fill: #dbeafe; stroke: #2563eb; stroke-width: 3; } +.architecture-node.state rect { fill: #ecfdf5; stroke: #059669; } +.architecture-node.external rect { fill: #f8fafc; stroke-dasharray: 5 4; } +.architecture-node text { fill: var(--text); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } +.architecture-evidence, .architecture-relationships { padding: 1rem; border-top: 1px solid var(--line); } +.architecture-evidence h2, .architecture-relationships h2 { margin-top: 0; font-size: 1rem; } +.architecture-evidence pre { white-space: pre-wrap; background: color-mix(in srgb, var(--panel) 90%, #64748b); padding: .75rem; border-radius: 6px; } +.architecture-relationships { overflow-x: auto; } +.architecture-relationships > input { width: min(28rem, 100%); padding: .5rem; margin-bottom: .65rem; border: 1px solid var(--line); border-radius: 6px; } +.architecture-relationships table { width: 100%; border-collapse: collapse; font-size: .82rem; } +.architecture-relationships th, .architecture-relationships td { padding: .5rem; text-align: left; border-bottom: 1px solid var(--line); } +@media (max-width: 800px) { .architecture-workspace { grid-template-columns: 1fr; } .architecture-members { position: static; max-height: 18rem; } .architecture-header { align-items: flex-start; flex-direction: column; gap: .5rem; } } * { box-sizing: border-box; } a { color: var(--link); @@ -30,6 +76,65 @@ height: 100vh; min-height: 0; overflow: hidden; + position: relative; + } + .app:has(.sidebar-toggle:not(:checked)) { + grid-template-columns: 0px 1fr; + } + .sidebar-toggle { + display: none; + } + .sidebar-collapse-container { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid var(--line); + background: var(--panel); + } + .sidebar-collapse-btn { + font-size: 11px; + color: var(--muted); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + user-select: none; + } + .sidebar-collapse-btn:hover { + color: var(--text); + } + .sidebar-expand-btn { + display: none; + position: absolute; + left: 8px; + top: 8px; + z-index: 1000; + width: 28px; + height: 28px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 6px; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); + color: var(--muted); + } + .sidebar-expand-btn:hover { + color: var(--text); + background: var(--hover); + } + .sidebar-toggle:not(:checked) ~ aside { + display: none !important; + } + .sidebar-toggle:not(:checked) ~ main { + grid-column: 1 / span 2; + padding-left: 44px; + position: relative; + } + .sidebar-toggle:not(:checked) ~ main .sidebar-expand-btn { + display: flex; } aside { border-right: 1px solid var(--line); @@ -487,33 +592,100 @@ border-top: 1px solid var(--line); padding-top: 14px; } - .dashboard-disclosure { - padding-top: 0; + .dashboard-panel { margin-top: 0; border-top: 0; padding-top: 0; } + .dashboard-panel[hidden] { display: none; } + .dashboard-section-body { + margin-top: 10px; + } + .dashboard-section-switcher { + margin-top: 16px; } - .dashboard-disclosure summary { + .dashboard-section-bar { display: flex; - align-items: center; - gap: 6px; - min-height: 28px; - padding-top: 14px; + max-width: 100%; + overflow-x: auto; + border: 1px solid var(--line); + border-radius: 7px; + background: #f8fafc; + scrollbar-width: thin; + } + .dashboard-section-bar button { + flex: 0 0 auto; + min-height: 38px; + margin: 0; + border: 0; + border-right: 1px solid var(--line); + border-radius: 0; + padding: 8px 12px; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 600; + white-space: nowrap; cursor: pointer; - list-style: none; } - .dashboard-disclosure summary::-webkit-details-marker { display: none; } - .dashboard-disclosure h2 { margin: 0; } - .dashboard-disclosure-arrow::before { - content: ">"; - display: inline-block; - inline-size: 12px; - text-align: center; - transform: rotate(0deg); - transition: transform 120ms ease; + .dashboard-section-bar button:last-child { + border-right: 0; + } + .dashboard-section-bar button:hover { + background: #eef2f7; + color: var(--text); } - .dashboard-disclosure[open] .dashboard-disclosure-arrow::before { - transform: rotate(90deg); + .dashboard-section-bar button.active { + background: #1d4ed8; + color: #fff; + box-shadow: inset 0 -3px 0 #1e3a8a; } - .dashboard-section-body { - margin-top: 10px; + .dashboard-section-bar button:focus-visible { + position: relative; + z-index: 1; + outline: 2px solid #2563eb; + outline-offset: -2px; + } + .dashboard-section-switcher > .dashboard-panel .dashboard-section-body { + margin-top: 12px; + } + .queue-see-more { + margin: 10px 0 0; + } + .queue-see-more a, + .queue-pagination a { + color: #1d4ed8; + font-weight: 650; + text-decoration: none; + } + .queue-see-more a:hover, + .queue-pagination a:hover { + text-decoration: underline; + } + .queue-page-header { + display: grid; + gap: 5px; + margin-bottom: 14px; + } + .queue-page-header h1 { + margin: 0; + font-size: 20px; + } + .queue-page-header p { + margin: 0; + color: var(--muted); + } + .queue-back { + width: max-content; + color: #1d4ed8; + text-decoration: none; + } + .queue-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-top: 14px; + } + .queue-pagination span { + color: var(--muted); + font-size: 12px; } .dashboard-bar-row { display: grid; @@ -653,6 +825,63 @@ font-weight: 700; text-transform: uppercase; } + .coverage-table .status-col, + .coverage-table .directory-status-cell { + width: 38px; + min-width: 38px; + padding-inline: 6px; + text-align: center; + } + .directory-health { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + min-height: 24px; + border-radius: 5px; + cursor: help; + } + .directory-health-current { + color: #16a34a; + } + .directory-health-caution { + color: #b45309; + background: #fffbeb; + } + .directory-health-caution:hover, + .directory-health-caution:focus-visible { + color: #92400e; + background: #fef3c7; + outline: 2px solid rgba(217, 119, 6, 0.3); + } + .directory-health-tooltip { + position: absolute; + z-index: 30; + top: calc(100% + 5px); + left: 0; + display: none; + width: min(420px, 75vw); + border: 1px solid #d97706; + border-radius: 7px; + padding: 10px 12px; + background: #fffdf5; + color: var(--text); + text-align: left; + font-size: 12px; + font-weight: 400; + line-height: 1.45; + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.16); + } + .directory-health-current .directory-health-tooltip { + border-color: #86efac; + background: #f0fdf4; + } + .directory-health:hover .directory-health-tooltip, + .directory-health:focus .directory-health-tooltip, + .directory-health:focus-within .directory-health-tooltip { + display: block; + } .sort-link { display: inline-flex; gap: 5px; @@ -794,7 +1023,7 @@ background: transparent; } .row.hazard-open .hazard-rail { background: #7f1d1d; } - .row.hazard-verified .hazard-rail { background: #cbd5e1; } + .row.hazard-verified .hazard-rail { background: #166534; } .gutter { min-width: 100px; text-align: right; @@ -869,6 +1098,114 @@ #layer-comment-folding:checked ~ .viewer .row.comment-fold-hidden { display: none; } + .fn-fold-toggle { display: none; } + .fn-fold-control { + border-radius: 3px; + color: #475569; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + inline-size: 18px; + min-height: 18px; + } + .fn-fold-control *, + .fn-fold-arrow, + .fn-fold-arrow::before, + .fn-fold-control::before, + .fn-fold-control::after { + cursor: pointer; + } + .fn-fold-control:hover, + .fn-fold-control:focus-visible { + background: rgba(71, 85, 105, 0.14); + color: #0f172a; + outline: 1px solid rgba(71, 85, 105, 0.28); + } + .fn-fold-arrow::before { + content: ">"; + display: inline-block; + inline-size: 12px; + text-align: center; + transform: rotate(0deg); + transition: transform 120ms ease; + } + .fn-fold-toggle:checked ~ .ln .fn-fold-arrow::before { transform: rotate(0deg); } + .fn-fold-toggle:not(:checked) ~ .ln .fn-fold-arrow::before { transform: rotate(90deg); } + .fn-fold-toggle:checked ~ .ln, + .fn-fold-toggle:checked ~ .gutter, + .fn-fold-toggle:checked ~ .source-text, + .fn-fold-toggle:checked ~ .blame-cell { border-bottom: 1px dashed rgba(100, 116, 139, 0.28); } + .row.fn-fold-hidden { + display: none !important; + } + #layer-comment-folding:checked ~ .viewer .row.comment-fold-hidden.fn-fold-child { + display: none; + } + .viewer .fn-fold-toggle:checked ~ .source-text .fold-full-source { display: none; } + .viewer .fn-fold-toggle:checked ~ .source-text .fold-collapsed-source { display: inline; } + .outline a.private-symbol .outline-name { + color: var(--muted); + } + #layer-private-folding:checked ~ .topbar .private-fold-layer .switch-on { display: inline-block; } + #layer-private-folding:checked ~ .topbar .private-fold-layer .switch-off { display: none; } + #layer-private-folding:not(:checked) ~ .topbar .private-fold-layer .switch-on { display: none; } + #layer-private-folding:not(:checked) ~ .topbar .private-fold-layer .switch-off { display: inline-block; } + + /* Directory views hazards / coverage toggle */ + .dashboard-section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + .dashboard-section-header h2 { + margin: 0; + } + #dir-mode-coverage:checked ~ .dashboard .view-toggle label[for="dir-mode-coverage"], + #dir-mode-hazards:checked ~ .dashboard .view-toggle label[for="dir-mode-hazards"] { + background: #eef2f7; + color: var(--text); + font-weight: 600; + } + .coverage-table .haz-col { display: none !important; } + .coverage-table .haz-bar-wrapper { display: none !important; } + #dir-mode-hazards:checked ~ .dashboard .coverage-table .cov-col { display: none !important; } + #dir-mode-hazards:checked ~ .dashboard .coverage-table .cov-bar-wrapper { display: none !important; } + #dir-mode-hazards:checked ~ .dashboard .coverage-table .haz-col { display: table-cell !important; } + #dir-mode-hazards:checked ~ .dashboard .coverage-table .haz-bar-wrapper { display: block !important; } + + /* Hazard quality bar styling */ + .hazard-bar { + display: flex; + height: 8px; + min-width: 130px; + border: 1px solid rgba(100, 116, 139, 0.22); + background: #e2e8f0; + border-radius: 4px; + overflow: hidden; + } + .hazard-bar span { + height: 100%; + } + .hazard-mutant-killed { + background: #166534; + } + .hazard-covered-only { + background: #86efac; + } + .hazard-missed { + background: transparent; + } + + /* Percentage color coding */ + .coverage-percent.pct-green { color: #166534 !important; } + .coverage-percent.pct-yellow { color: #d97706 !important; } + .coverage-percent.pct-orange { color: #ea580c !important; } + .coverage-percent.pct-light-red { color: #ef4444 !important; } + .coverage-percent.pct-dark-red { color: #b91c1c !important; } + .row.dark-arm .line-meta { color: var(--dark-arm); font-weight: 700; @@ -896,11 +1233,14 @@ line-height: 18px; } .decomplex-finding, + .sql-cov-finding, .espalier-finding, .nil-kill-finding, .lint-finding { color: var(--muted); } .decomplex-finding:hover, .decomplex-finding:focus-visible, + .sql-cov-finding:hover, + .sql-cov-finding:focus-visible, .espalier-finding:hover, .espalier-finding:focus-visible, .nil-kill-finding:hover, @@ -911,12 +1251,25 @@ .line-meta i, .bug-history i, .decomplex-finding i, + .sql-cov-finding i, .espalier-finding i, .nil-kill-finding i, .lint-finding i { inline-size: 14px; text-align: center; } + .finding-stale { color: #b45309; } + .finding-stale-badge { + display: inline-block; + padding: 1px 5px; + border: 1px solid #d97706; + border-radius: 999px; + background: #fffbeb; + color: #92400e; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + } .line-panel { display: none; grid-column: 4 / -1; @@ -932,6 +1285,7 @@ .meta-toggle:checked ~ .meta-panel, .hazard-toggle:checked ~ .hazard-panel, .decomplex-toggle:checked ~ .decomplex-panel, + .sql-cov-toggle:checked ~ .sql-cov-panel, .espalier-toggle:checked ~ .espalier-panel, .nil-kill-toggle:checked ~ .nil-kill-panel, .lint-toggle:checked ~ .lint-panel, @@ -939,6 +1293,7 @@ .row.meta-open .meta-panel, .row.hazard-panel-open .hazard-panel, .row.decomplex-open .decomplex-panel, + .row.sql-cov-open .sql-cov-panel, .row.espalier-open .espalier-panel, .row.nil-kill-open .nil-kill-panel, .row.lint-open .lint-panel { display: block; } diff --git a/gems/lineage/src/ui/assets/app.js b/gems/lineage/src/ui/assets/app.js index 2068762f1..4c26b32a0 100644 --- a/gems/lineage/src/ui/assets/app.js +++ b/gems/lineage/src/ui/assets/app.js @@ -110,6 +110,40 @@ input.addEventListener("change", () => setFoldRows(input)); }; + const setFnFoldRows = (input) => { + const foldId = input.dataset.foldId; + const sourceView = input.closest(".source-view"); + const row = input.closest(".row"); + if (row) { + row.classList.toggle("fn-fold-collapsed", input.checked); + row.classList.toggle("fn-fold-expanded", !input.checked); + } + if (!foldId || !sourceView) return; + sourceView + .querySelectorAll(`[data-fn-fold-child="${foldId}"]`) + .forEach((row) => row.classList.toggle("fn-fold-hidden", input.checked)); + }; + + const restoreFnFold = (input) => { + const key = input.dataset.persistKey; + const stored = key ? read(key) : null; + if (stored === null) { + if (input.classList.contains("private-fn-fold")) { + const privateFoldingLayer = document.getElementById("layer-private-folding"); + input.checked = privateFoldingLayer ? privateFoldingLayer.checked : true; + } else { + input.checked = false; + } + } else { + input.checked = (stored === "true"); + } + input.addEventListener("change", () => { + if (key) write(key, String(input.checked)); + setFnFoldRows(input); + }); + setFnFoldRows(input); + }; + const restoreWarningDismissal = (control) => { const key = control.dataset.dismissKey; const warning = control.closest(".warning"); @@ -185,15 +219,111 @@ }); }; + const setupDashboardSectionSwitchers = () => { + document.querySelectorAll("[data-dashboard-section-switcher]").forEach((switcher) => { + const buttons = Array.from(switcher.querySelectorAll("[data-dashboard-panel]")); + const panels = buttons + .map((button) => document.getElementById(button.dataset.dashboardPanel)) + .filter(Boolean); + if (!buttons.length || panels.length !== buttons.length) return; + + const select = (button) => { + const panel = document.getElementById(button.dataset.dashboardPanel); + buttons.forEach((candidate) => { + candidate.classList.remove("active"); + candidate.setAttribute("aria-selected", "false"); + }); + panels.forEach((candidate) => { candidate.hidden = true; }); + button.classList.add("active"); + button.setAttribute("aria-selected", "true"); + panel.hidden = false; + }; + + buttons.forEach((button, index) => { + button.id ||= `dashboard-section-tab-${index}`; + const panel = document.getElementById(button.dataset.dashboardPanel); + panel.setAttribute("role", "tabpanel"); + panel.setAttribute("aria-labelledby", button.id); + button.addEventListener("click", () => select(button)); + button.addEventListener("keydown", (event) => { + let target = null; + if (event.key === "ArrowRight") target = buttons[(index + 1) % buttons.length]; + if (event.key === "ArrowLeft") target = buttons[(index - 1 + buttons.length) % buttons.length]; + if (event.key === "Home") target = buttons[0]; + if (event.key === "End") target = buttons[buttons.length - 1]; + if (!target) return; + event.preventDefault(); + target.focus(); + select(target); + }); + }); + select(buttons[0]); + }); + }; + + const setupArchitectureView = () => { + const search = document.querySelector("[data-architecture-search]"); + if (search) { + search.addEventListener("input", () => { + const query = search.value.trim().toLowerCase(); + document.querySelectorAll(".architecture-member").forEach((member) => { + member.hidden = query && !String(member.dataset.memberName || "").toLowerCase().includes(query); + }); + }); + } + const relationshipSearch = document.querySelector("[data-relationship-search]"); + if (relationshipSearch) { + relationshipSearch.addEventListener("input", () => { + const query = relationshipSearch.value.trim().toLowerCase(); + document.querySelectorAll("[data-relationship-row]").forEach((row) => { + row.hidden = query && !row.textContent.toLowerCase().includes(query); + }); + }); + } + document.querySelectorAll("[data-architecture-fit]").forEach((button) => { + button.addEventListener("click", () => { + const viewport = button.closest(".architecture-focus")?.querySelector(".architecture-graph-viewport"); + if (viewport) viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" }); + }); + }); + const graphLinks = Array.from(document.querySelectorAll(".architecture-graph a")); + graphLinks.forEach((link, index) => { + link.addEventListener("keydown", (event) => { + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return; + event.preventDefault(); + const delta = event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1; + graphLinks[(index + delta + graphLinks.length) % graphLinks.length]?.focus(); + }); + }); + }; + document.addEventListener("DOMContentLoaded", () => { document - .querySelectorAll("input[data-persist-key]:not(.comment-fold-toggle)") + .querySelectorAll("input[data-persist-key]:not(.comment-fold-toggle):not(.fn-fold-toggle)") .forEach(restoreInput); document.querySelectorAll(".comment-fold-toggle[data-persist-key]").forEach(restoreCommentFold); + document.querySelectorAll(".fn-fold-toggle[data-persist-key]").forEach(restoreFnFold); + + const privateFoldingLayer = document.getElementById("layer-private-folding"); + if (privateFoldingLayer) { + privateFoldingLayer.addEventListener("change", () => { + document.querySelectorAll(".private-fn-fold").forEach(input => { + const key = input.dataset.persistKey; + const stored = key ? read(key) : null; + if (stored === null) { + input.checked = privateFoldingLayer.checked; + input.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + }); + } + document.querySelectorAll(".warning-dismiss[data-dismiss-key]").forEach(restoreWarningDismissal); document.querySelectorAll(".layers-panel label[for]").forEach(bindLayerLabel); document.querySelectorAll(".line-toggle").forEach(bindLineToggle); document.querySelectorAll(".gutter .line-icon[for]").forEach(bindLineToggleLabel); + setupDashboardSectionSwitchers(); setupClickableTokens(); + setupArchitectureView(); }); })(); diff --git a/gems/lineage/src/ui/controllers/architecture.rs b/gems/lineage/src/ui/controllers/architecture.rs new file mode 100644 index 000000000..dc7584bb6 --- /dev/null +++ b/gems/lineage/src/ui/controllers/architecture.rs @@ -0,0 +1,228 @@ +use super::super::*; + +pub(super) fn routes() -> Router { + Router::new() + .route( + "/api/architecture/owners/:owner_id", + get(api_architecture_owner_handler), + ) + .route( + "/api/architecture/functions/:node_id/neighborhood", + get(api_architecture_neighborhood_handler), + ) + .route( + "/api/architecture/state/:state_id/access", + get(api_architecture_state_handler), + ) + .route("/api/architecture/search", get(api_architecture_search_handler)) + .route("/architecture/unit/:node_id", get(architecture_page_handler)) + .route("/architecture/state/:node_id", get(architecture_page_handler)) +} + +async fn api_architecture_owner_handler( + State(state): State, + AxumPath(owner_id): AxumPath, +) -> Response { + architecture_json_response(&state, |storage| owner_inventory(storage, &owner_id)) +} + +async fn api_architecture_neighborhood_handler( + State(state): State, + AxumPath(node_id): AxumPath, + Query(query): Query, +) -> Response { + let limit = query.limit.unwrap_or(40).clamp(1, 100); + architecture_json_response(&state, |storage| node_neighborhood(storage, &node_id, limit)) +} + +async fn api_architecture_state_handler( + State(state): State, + AxumPath(state_id): AxumPath, +) -> Response { + architecture_json_response(&state, |storage| state_access(storage, &state_id)) +} + +async fn api_architecture_search_handler( + State(state): State, + Query(query): Query, +) -> Response { + architecture_json_response(&state, |storage| { + architecture_search(storage, query.owner.as_deref(), query.q.as_deref().unwrap_or_default()) + }) +} + +fn architecture_json_response( + state: &UiServerState, + operation: impl FnOnce(&Storage) -> Result, +) -> Response { + let storage = match Storage::open_existing(state.db.as_ref()) { + Ok(storage) => storage, + Err(error) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, error), + }; + match operation(&storage) { + Ok(mut value) => { + annotate_architecture_freshness(&mut value, state.repo.as_ref()); + Json(value).into_response() + } + Err(error) => error_json(StatusCode::NOT_FOUND, error), + } +} + +async fn architecture_page_handler( + State(state): State, + AxumPath(node_id): AxumPath, + Query(query): Query, +) -> Response { + let storage = match Storage::open_existing(state.db.as_ref()) { + Ok(storage) => storage, + Err(error) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, error), + }; + let mut neighborhood = match node_neighborhood(&storage, &node_id, query.limit.unwrap_or(40).clamp(1, 100)) { + Ok(value) => value, + Err(error) => return error_response(StatusCode::NOT_FOUND, error), + }; + annotate_architecture_freshness(&mut neighborhood, state.repo.as_ref()); + let selected = &neighborhood["selected"]; + let owner_id = selected.get("owner_id").and_then(Value::as_str).unwrap_or(&node_id); + let inventory = owner_inventory(&storage, owner_id).unwrap_or_else(|_| json!({"owner": selected, "members": []})); + Html(render_architecture_page( + &inventory, + &neighborhood, + query.lens.as_deref().unwrap_or("combined"), + )).into_response() +} + +fn annotate_architecture_freshness(value: &mut Value, repo: &Path) { + let current = Repository::open(repo).ok().and_then(|repository| repository.head().ok().and_then(|head| head.target()).map(|oid| oid.to_string())); + let artifact_commit = value.pointer("/artifact/commit").and_then(Value::as_str).unwrap_or(""); + let stale = current.as_deref().is_some_and(|commit| !artifact_commit.is_empty() && commit != artifact_commit); + if let Some(artifact) = value.get_mut("artifact") { + artifact["current_commit"] = json!(current); + artifact["stale"] = json!(stale); + } +} + +fn render_architecture_page(inventory: &Value, neighborhood: &Value, lens: &str) -> String { + let owner = &inventory["owner"]; + let selected = &neighborhood["selected"]; + let owner_name = owner.get("name").and_then(Value::as_str).unwrap_or("Architecture"); + let selected_name = selected.get("name").and_then(Value::as_str).unwrap_or(""); + let members = inventory.get("members").and_then(Value::as_array).cloned().unwrap_or_default(); + let mut edges = neighborhood.get("edges").and_then(Value::as_array).cloned().unwrap_or_default(); + let omitted = neighborhood.get("omitted_relationships").and_then(Value::as_array).cloned().unwrap_or_default(); + let all_edges = edges.iter().chain(omitted.iter()).cloned().collect::>(); + edges.retain(|edge| architecture_edge_in_lens(edge, lens)); + let nodes = neighborhood.get("nodes").and_then(Value::as_array).cloned().unwrap_or_default(); + let commit = neighborhood.pointer("/artifact/commit").and_then(Value::as_str).unwrap_or(""); + + let mut out = String::new(); + out.push_str(""); + out.push_str(""); out.push_str(&html_escape(owner_name)); out.push_str(" architecture"); + out.push_str("
"); + out.push_str("
← Lineage

"); + out.push_str(&html_escape(owner_name)); out.push_str("

"); + out.push_str(&html_escape(owner.get("path").and_then(Value::as_str).unwrap_or(""))); + out.push_str("

Focused: "); out.push_str(&html_escape(selected_name)); + out.push_str("artifact "); out.push_str(&html_escape(&short_hash(commit))); out.push_str("
"); + if neighborhood.pointer("/artifact/stale").and_then(Value::as_bool) == Some(true) { + out.push_str("
Architecture artifact is stale. Regenerate it for the currently viewed commit.
"); + } + out.push_str("
Focused neighborhood
"); + out.push_str(&render_architecture_svg(selected, &nodes, &edges)); + out.push_str("

Why this is highlighted

"); + let pressure = members.iter().find(|member| member.get("id") == selected.get("id")).and_then(|member| member.get("pressure")); + if let Some(pressure) = pressure { + out.push_str("

"); out.push_str(&format!("{:.1} architecture pressure", pressure.get("score").and_then(Value::as_f64).unwrap_or(0.0))); out.push_str("

");
+        out.push_str(&html_escape(&serde_json::to_string_pretty(&pressure["explanation"]).unwrap_or_default())); out.push_str("
"); + } else { out.push_str("

Pressure is not scored for this node.

"); } + out.push_str("

All known relationships

"); + let node_names = nodes.iter().filter_map(|node| Some((node.get("id")?.as_str()?, node.get("name")?.as_str()?))).collect::>(); + for edge in &all_edges { + let source = edge.get("source").and_then(Value::as_str).unwrap_or(""); + let target = edge.get("target").and_then(Value::as_str).unwrap_or(""); + out.push_str(""); + } + out.push_str("
KindFromToConfidenceEvidence
"); out.push_str(&html_escape(edge.get("kind").and_then(Value::as_str).unwrap_or(""))); out.push_str(""); + out.push_str(&html_escape(node_names.get(source).copied().unwrap_or(source))); out.push_str(""); out.push_str(&html_escape(node_names.get(target).copied().unwrap_or(target))); out.push_str(""); + out.push_str(&html_escape(edge.get("confidence").and_then(Value::as_str).unwrap_or(""))); out.push_str(""); + if let Some(span) = edge.get("spans").and_then(Value::as_array).and_then(|spans| spans.first()) { + let path = span.get("path").and_then(Value::as_str).unwrap_or(""); let line = span.get("start_line").and_then(Value::as_i64).unwrap_or(1); + out.push_str(""); out.push_str(&html_escape(path)); out.push(':'); out.push_str(&line.to_string()); out.push_str(""); + } + out.push_str("
"); + out +} + +fn architecture_edge_in_lens(edge: &Value, lens: &str) -> bool { + let kind = edge.get("kind").and_then(Value::as_str).unwrap_or(""); + match lens { + "calls" => kind.contains("call") || kind == "delegation", + "state" => matches!(kind, "reads" | "writes"), + _ => true, + } +} + +fn render_architecture_svg(selected: &Value, nodes: &[Value], edges: &[Value]) -> String { + let selected_id = selected.get("id").and_then(Value::as_str).unwrap_or(""); + let inbound = edges.iter().filter_map(|edge| (edge.get("target").and_then(Value::as_str) == Some(selected_id)).then(|| edge.get("source").and_then(Value::as_str)).flatten()).collect::>(); + let outbound = edges.iter().filter_map(|edge| (edge.get("source").and_then(Value::as_str) == Some(selected_id)).then(|| edge.get("target").and_then(Value::as_str)).flatten()).collect::>(); + let mut positions = HashMap::<&str, (i32, i32)>::new(); + positions.insert(selected_id, (400, 180)); + for (index, id) in inbound.iter().enumerate() { positions.insert(id, (100, 60 + index as i32 * 90)); } + for (index, id) in outbound.iter().enumerate() { positions.insert(id, (700, 60 + index as i32 * 90)); } + let height = (inbound.len().max(outbound.len()).max(2) * 90 + 70).max(320); + let mut out = format!("
"); + out.push_str(""); + for edge in edges { + let source = edge.get("source").and_then(Value::as_str).unwrap_or(""); let target = edge.get("target").and_then(Value::as_str).unwrap_or(""); + let (Some((sx, sy)), Some((tx, ty))) = (positions.get(source), positions.get(target)) else { continue }; + let dashed = edge.get("confidence").and_then(Value::as_str) != Some("high") || edge.get("conditional").and_then(Value::as_bool) == Some(true); + out.push_str(""); + out.push_str(&html_escape(edge.get("kind").and_then(Value::as_str).unwrap_or("relationship"))); out.push_str(""); + } + for node in nodes { + let id = node.get("id").and_then(Value::as_str).unwrap_or(""); let Some((x, y)) = positions.get(id) else { continue }; + let kind = node.get("kind").and_then(Value::as_str).unwrap_or(""); let route = if kind == "state" { "state" } else { "unit" }; + if kind != "aggregate" { out.push_str(""); } + out.push_str(""); out.push_str(&html_escape(node.get("name").and_then(Value::as_str).unwrap_or(id))); out.push_str(""); + if kind != "aggregate" { out.push_str(""); } + } + out.push_str("
"); out +} + +fn capitalize(value: &str) -> String { + let mut chars = value.chars(); + chars.next().map(|first| first.to_uppercase().collect::() + chars.as_str()).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn architecture_routes_build() { + let _ = routes(); + } +} diff --git a/gems/lineage/src/ui/controllers/assets.rs b/gems/lineage/src/ui/controllers/assets.rs new file mode 100644 index 000000000..2507d8d0b --- /dev/null +++ b/gems/lineage/src/ui/controllers/assets.rs @@ -0,0 +1,57 @@ +use super::super::*; + +pub(super) fn routes() -> Router { + Router::new() + .route("/assets/*path", get(asset_handler)) + .route("/favicon.ico", get(favicon_handler)) +} + +async fn asset_handler(AxumPath(path): AxumPath) -> Response { + let path = path.trim_start_matches('/'); + let embedded_path = embedded_asset_path(path); + let Some(asset) = EmbeddedUi::get(&embedded_path) else { + return StatusCode::NOT_FOUND.into_response(); + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, asset_content_type(&embedded_path)) + .header(header::CACHE_CONTROL, "no-store") + .body(Body::from(asset.data.into_owned())) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()) +} + +async fn favicon_handler() -> Response { + Response::builder() + .status(StatusCode::NO_CONTENT) + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::empty()) + .unwrap_or_else(|_| StatusCode::NO_CONTENT.into_response()) +} + +fn embedded_asset_path(path: &str) -> String { + format!("assets/{}", path.trim_start_matches('/')) +} + +fn asset_content_type(path: &str) -> &'static str { + match Path::new(path).extension().and_then(|extension| extension.to_str()) { + Some("css") => "text/css; charset=utf-8", + Some("js") => "text/javascript; charset=utf-8", + Some("html") => "text/html; charset=utf-8", + Some("json") => "application/json", + Some("svg") => "image/svg+xml", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_asset_paths_resolve_css_and_js() { + assert_eq!(embedded_asset_path("app.css"), "assets/app.css"); + assert_eq!(embedded_asset_path("/app.js"), "assets/app.js"); + assert!(EmbeddedUi::get(&embedded_asset_path("app.css")).is_some()); + assert!(EmbeddedUi::get(&embedded_asset_path("app.js")).is_some()); + } +} diff --git a/gems/lineage/src/ui/controllers/index.rs b/gems/lineage/src/ui/controllers/index.rs new file mode 100644 index 000000000..5bdb9eb9a --- /dev/null +++ b/gems/lineage/src/ui/controllers/index.rs @@ -0,0 +1,89 @@ +use super::super::*; + +pub(super) fn routes() -> Router { + Router::new() + .route("/", get(index_handler)) + .route("/index.html", get(index_handler)) + .route("/api/files", get(api_files_handler)) + .route("/api/dashboard", get(api_dashboard_handler)) +} + +async fn index_handler( + State(state): State, + Query(query): Query, +) -> Response { + let storage = match Storage::open_existing(state.db.as_ref()) { + Ok(storage) => storage, + Err(error) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, error), + }; + let scope = CoverageScope::from_repo(state.repo.as_ref()); + let commit = query + .commit + .as_deref() + .filter(|value| !value.is_empty() && *value != "current"); + let filter = query.q.as_deref().unwrap_or_default(); + let sort = query + .sort + .as_deref() + .map(CoverageSort::parse) + .unwrap_or(CoverageSort::Path); + match render_index_page( + &storage, + state.repo.as_ref(), + state.overlays.as_ref(), + &scope, + query.path.as_deref(), + query.dir.as_deref(), + commit, + filter, + sort, + query.queue.as_deref(), + query.page.unwrap_or(1), + ) { + Ok(body) => Html(body).into_response(), + Err(error) => error_response(StatusCode::INTERNAL_SERVER_ERROR, error), + } +} + +async fn api_files_handler(State(state): State) -> Response { + let storage = match Storage::open_existing(state.db.as_ref()) { + Ok(storage) => storage, + Err(error) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, error), + }; + let scope = CoverageScope::from_repo(state.repo.as_ref()); + match file_index_with_scope(&storage, &scope, Some(state.repo.as_ref())) { + Ok(files) => Json(files).into_response(), + Err(error) => error_json(StatusCode::INTERNAL_SERVER_ERROR, error), + } +} + +async fn api_dashboard_handler( + State(state): State, + Query(query): Query, +) -> Response { + let storage = match Storage::open_existing(state.db.as_ref()) { + Ok(storage) => storage, + Err(error) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, error), + }; + let scope = CoverageScope::from_repo(state.repo.as_ref()); + let directory = query.dir.as_deref().unwrap_or_default(); + match dashboard_summary_for_directory_with_scope_and_repo( + &storage, + directory, + &scope, + Some(state.repo.as_ref()), + ) { + Ok(dashboard) => Json(dashboard).into_response(), + Err(error) => error_json(StatusCode::INTERNAL_SERVER_ERROR, error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn index_directory_review_and_test_routes_build() { + let _ = routes(); + } +} diff --git a/gems/lineage/src/ui/controllers/mod.rs b/gems/lineage/src/ui/controllers/mod.rs new file mode 100644 index 000000000..0c5822631 --- /dev/null +++ b/gems/lineage/src/ui/controllers/mod.rs @@ -0,0 +1,15 @@ +use super::*; + +mod architecture; +mod assets; +mod index; +mod source; + +pub(super) fn router(state: UiServerState) -> Router { + Router::new() + .merge(index::routes()) + .merge(source::routes()) + .merge(architecture::routes()) + .merge(assets::routes()) + .with_state(state) +} diff --git a/gems/lineage/src/ui/controllers/source.rs b/gems/lineage/src/ui/controllers/source.rs new file mode 100644 index 000000000..8874c947c --- /dev/null +++ b/gems/lineage/src/ui/controllers/source.rs @@ -0,0 +1,69 @@ +use super::super::*; + +pub(super) fn routes() -> Router { + Router::new() + .route("/api/source", get(api_source_handler)) + .route("/api/definition", get(api_definition_handler)) +} + +async fn api_source_handler( + State(state): State, + Query(query): Query, +) -> Response { + let Some(source_path) = query.path.as_deref() else { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "missing path" })), + ) + .into_response(); + }; + let storage = match Storage::open_existing(state.db.as_ref()) { + Ok(storage) => storage, + Err(error) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, error), + }; + let commit = query + .commit + .as_deref() + .filter(|value| !value.is_empty() && *value != "current"); + match source_payload_with_overlays( + &storage, + state.repo.as_ref(), + source_path, + commit, + state.overlays.as_ref(), + ) { + Ok(payload) => Json(payload).into_response(), + Err(error) => error_json(StatusCode::NOT_FOUND, error), + } +} + +async fn api_definition_handler( + State(state): State, + Query(query): Query, +) -> Response { + let storage = match Storage::open_existing(state.db.as_ref()) { + Ok(storage) => storage, + Err(error) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, error), + }; + let commit = query.commit.as_deref().filter(|value| !value.is_empty() && *value != "current"); + match storage.find_definitions(&query.name, commit, query.path.as_deref()) { + Ok(definitions) => { + let results: Vec = definitions + .into_iter() + .map(|(path, line)| DefinitionResult { path, line }) + .collect(); + Json(results).into_response() + } + Err(error) => error_json(StatusCode::INTERNAL_SERVER_ERROR, error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_routes_build() { + let _ = routes(); + } +} diff --git a/gems/lineage/src/ui/lsp.rs b/gems/lineage/src/ui/lsp.rs index 4dc7e3f12..a3c29204b 100644 --- a/gems/lineage/src/ui/lsp.rs +++ b/gems/lineage/src/ui/lsp.rs @@ -550,6 +550,7 @@ mod tests { UiLineAnnotation { line: 7, covered: true, + is_partial: false, mutant_tested: true, test_types: vec!["unit".to_string()], distinct_tests: 2, diff --git a/gems/lineage/src/ui/templates/app.html b/gems/lineage/src/ui/templates/app.html index a9b4d3c1f..1469eb052 100644 --- a/gems/lineage/src/ui/templates/app.html +++ b/gems/lineage/src/ui/templates/app.html @@ -1,8 +1,17 @@
+ + {{ sidebar|safe }}
+ {{ main|safe }}
diff --git a/gems/lineage/src/ui/templates/coverage_table.html b/gems/lineage/src/ui/templates/coverage_table.html index 6cd81db9a..43c581ed7 100644 --- a/gems/lineage/src/ui/templates/coverage_table.html +++ b/gems/lineage/src/ui/templates/coverage_table.html @@ -1,18 +1,23 @@ + - - - - + + + + + + + - + + {% if empty %} - + {% else %} {{ rows|safe }} {% endif %} diff --git a/gems/lineage/src/ui/templates/dashboard.html b/gems/lineage/src/ui/templates/dashboard.html index ff9195a32..da6aa8a0c 100644 --- a/gems/lineage/src/ui/templates/dashboard.html +++ b/gems/lineage/src/ui/templates/dashboard.html @@ -1,13 +1,37 @@
+ +
{{ branch_context|safe }} {{ warnings|safe }} - {{ active_hazards|safe }} - {{ highest_hazard_files|safe }} - {{ highest_risk_units|safe }} - {{ highest_architecture_risks|safe }} +
+
+ + + + + + + + +
+ {{ active_hazards|safe }} + {{ finding_changes|safe }} + {{ review_next|safe }} + {{ test_next|safe }} + {{ highest_hazard_files|safe }} + {{ highest_risk_units|safe }} + {{ highest_architecture_risks|safe }} + {{ highest_complexity_functions|safe }} +
-

{{ code_tree_heading }}

+
+

{{ code_tree_heading }}

+
+ + +
+
{{ code_tree|safe }}
diff --git a/gems/lineage/src/ui/templates/dashboard_disclosure.html b/gems/lineage/src/ui/templates/dashboard_disclosure.html index 3ac90f859..c83539536 100644 --- a/gems/lineage/src/ui/templates/dashboard_disclosure.html +++ b/gems/lineage/src/ui/templates/dashboard_disclosure.html @@ -1,9 +1,5 @@ -
- - -

{{ title }}

-
+
{{ body|safe }}
-
+ diff --git a/gems/lineage/src/ui/templates/dashboard_sidebar.html b/gems/lineage/src/ui/templates/dashboard_sidebar.html index a7602addc..cd64c659b 100644 --- a/gems/lineage/src/ui/templates/dashboard_sidebar.html +++ b/gems/lineage/src/ui/templates/dashboard_sidebar.html @@ -1,8 +1,3 @@ -
-

Lineage

-
{{ summary }}
- {{ nav|safe }} -
{% if show_directory_input %} diff --git a/gems/lineage/src/ui/templates/index.html b/gems/lineage/src/ui/templates/index.html index 31e21ec64..5d51fd12f 100644 --- a/gems/lineage/src/ui/templates/index.html +++ b/gems/lineage/src/ui/templates/index.html @@ -4,8 +4,8 @@ - - + + {{ title }} diff --git a/gems/lineage/src/ui/templates/layers_menu.html b/gems/lineage/src/ui/templates/layers_menu.html index 7c568919f..a2c1a9f32 100644 --- a/gems/lineage/src/ui/templates/layers_menu.html +++ b/gems/lineage/src/ui/templates/layers_menu.html @@ -27,5 +27,10 @@ On Off +
diff --git a/gems/lineage/src/ui/templates/source_sidebar.html b/gems/lineage/src/ui/templates/source_sidebar.html index dca03e135..c9e0c35ba 100644 --- a/gems/lineage/src/ui/templates/source_sidebar.html +++ b/gems/lineage/src/ui/templates/source_sidebar.html @@ -1,8 +1,3 @@ -
-

Lineage

-
{{ path }}
- {{ nav|safe }} -
{% if show_empty_outline %} "); + out.push_str(""); out.push_str(""); out } @@ -6049,6 +6339,90 @@ fn render_source_view( .collect::>(); let comment_folds = detect_comment_folds(&payload.path, &payload.lines); let comment_fold_lines = comment_fold_lines(&comment_folds); + let fn_folds = payload.symbols.iter() + .filter(|symbol| { + (symbol.kind == "function" || symbol.kind == "method") + && symbol.start_line < symbol.end_line + }) + .enumerate() + .map(|(idx, symbol)| { + let is_private = { + let path = &payload.path; + if path.ends_with(".zig") { + let def_line = payload.lines.get(symbol.start_line as usize - 1).map(|s| s.trim()).unwrap_or(""); + def_line.contains("fn ") && !def_line.contains("pub fn") + } else if path.ends_with(".clear") { + let def_line = payload.lines.get(symbol.start_line as usize - 1).map(|s| s.trim()).unwrap_or(""); + let upper = def_line.to_uppercase(); + upper.contains("FN ") && !upper.contains("PUB FN") + } else if path.ends_with(".rb") { + if symbol.name.starts_with('_') { + true + } else { + let mut found_private = false; + let start_idx = symbol.start_line as usize - 1; + for idx in (0..start_idx).rev() { + if let Some(line) = payload.lines.get(idx) { + let trimmed = line.trim(); + if trimmed == "private" { + found_private = true; + break; + } + if trimmed.starts_with("class ") || trimmed.starts_with("module ") || trimmed.starts_with("def ") { + break; + } + } + } + found_private + } + } else { + symbol.name.starts_with('_') + } + }; + let closing_token = { + if symbol.end_line as usize <= payload.lines.len() { + let last_line = payload.lines.get(symbol.end_line as usize - 1).map(|s| s.trim()).unwrap_or(""); + if last_line == "}" || last_line == "end" { + last_line.to_string() + } else if last_line.ends_with('}') { + "}".to_string() + } else if last_line.ends_with("end") { + "end".to_string() + } else { + String::new() + } + } else { + String::new() + } + }; + FunctionFold { + id: idx + 1, + start_line: symbol.start_line, + end_line: symbol.end_line, + is_private, + closing_token, + } + }) + .collect::>(); + let fn_fold_lines = { + let mut by_line = BTreeMap::new(); + for fold in &fn_folds { + for line in fold.start_line..=fold.end_line { + by_line.insert( + line, + FunctionFoldLine { + id: fold.id, + start_line: fold.start_line, + end_line: fold.end_line, + is_start: line == fold.start_line, + is_private: fold.is_private, + closing_token: fold.closing_token.clone(), + }, + ); + } + } + by_line + }; let covered = payload .annotations .iter() @@ -6096,6 +6470,7 @@ fn render_source_view( annotations.get(&line_no).copied(), blame.get(&line_no).copied(), comment_fold_lines.get(&line_no), + fn_fold_lines.get(&line_no), )); } let history = render_history(payload, filter); @@ -6162,6 +6537,7 @@ fn render_code_line( annotation: Option<&UiLineAnnotation>, blame: Option<&UiLineBlame>, comment_fold: Option<&CommentFoldLine>, + fn_fold: Option<&FunctionFoldLine>, ) -> String { let mut classes = vec!["row"]; if annotation.map(|a| a.covered).unwrap_or(false) { @@ -6170,7 +6546,7 @@ fn render_code_line( if annotation.map(|a| a.mutant_tested).unwrap_or(false) { classes.push("mutant"); } - if annotation.map(annotation_has_dark_arms).unwrap_or(false) { + if annotation.map(|a| a.covered && a.is_partial).unwrap_or(false) { classes.push("dark-arm"); } if annotation.map(|a| a.semantic_churn > 0.0).unwrap_or(false) { @@ -6182,6 +6558,9 @@ fn render_code_line( if comment_fold.map(|fold| !fold.is_start).unwrap_or(false) { classes.push("comment-fold-child"); } + if fn_fold.map(|fold| !fold.is_start).unwrap_or(false) { + classes.push("fn-fold-child"); + } if let Some(annotation) = annotation { if !annotation.hazards.is_empty() { if annotation.hazards.iter().all(|hazard| hazard.verified) { @@ -6220,16 +6599,24 @@ fn render_code_line( let fold_input_id = comment_fold .filter(|fold| fold.is_start) .map(|fold| format!("comment-fold-{}", fold.id)); + let fn_fold_input_id = fn_fold + .filter(|fold| fold.is_start) + .map(|fold| format!("fn-fold-{}", fold.id)); let fold_child_attr = comment_fold .filter(|fold| !fold.is_start) .map(|fold| format!(" data-comment-fold-child=\"{}\"", fold.id)) .unwrap_or_default(); + let fn_fold_child_attr = fn_fold + .filter(|fold| !fold.is_start) + .map(|fold| format!(" data-fn-fold-child=\"{}\"", fold.id)) + .unwrap_or_default(); let mut out = format!( - "
", + "
", line_no, classes.join(" "), fold_child_attr, + fn_fold_child_attr, style, ); if let Some(input_id) = &fold_input_id { @@ -6243,6 +6630,26 @@ fn render_code_line( out.push_str(&comment_fold.map(|fold| fold.id).unwrap_or_default().to_string()); out.push_str("\">"); } + if let Some(input_id) = &fn_fold_input_id { + out.push_str(""); + } if has_bug_history { out.push_str(""); + out.push('>'); if let Some(annotation) = annotation { if has_hazards { out.push_str(&render_hazard_control(annotation, &hazard_id)); @@ -6298,6 +6705,10 @@ fn render_code_line( fold.end_line.saturating_sub(fold.start_line) + 1 )); out.push_str("\">"); + } else if let (Some(input_id), Some(_fold)) = (&fn_fold_input_id, fn_fold) { + out.push_str(""); } else { out.push_str(""); } @@ -6324,6 +6735,25 @@ fn render_code_line( path, line_no, source, annotation, )); } + } else if let Some(fold) = fn_fold { + if fold.is_start { + out.push_str(""); + out.push_str(&highlight_source_line_with_dark_arms( + path, line_no, source, annotation, + )); + out.push_str(""); + out.push_str(&highlight_source_line_with_dark_arms( + path, + line_no, + &collapsed_function_source(source, &fold.closing_token), + annotation, + )); + out.push_str(""); + } else { + out.push_str(&highlight_source_line_with_dark_arms( + path, line_no, source, annotation, + )); + } } else { out.push_str(&highlight_source_line_with_dark_arms( path, line_no, source, annotation, @@ -6513,6 +6943,15 @@ fn collapsed_comment_source(source: &str) -> String { format!("{} ...", source.trim_end()) } +fn collapsed_function_source(source: &str, closing_token: &str) -> String { + let trimmed = source.trim_end(); + if closing_token.is_empty() { + format!("{} ...", trimmed) + } else { + format!("{} ... {}", trimmed, closing_token) + } +} + fn render_line_details_control(meta_id: &str) -> String { let mut out = String::new(); out.push_str("
Analyzer status {{ name_header|safe }}{{ total_header|safe }}{{ covered_header|safe }}{{ partial_header|safe }}{{ missed_header|safe }}{{ total_header|safe }}{{ covered_header|safe }}{{ partial_header|safe }}{{ missed_header|safe }}HazardsCoveredKilled Coverage{{ percent_header|safe }}{{ percent_header|safe }}%
No tracked files in this directory.
No tracked files in this directory.
"); + if let Some(analyzer_status) = analyzer_status { + out.push_str(analyzer_status); + } + out.push_str(""); if let Some(href) = href { out.push_str(""); } - out.push_str(""); + // Coverage mode columns + out.push_str(""); out.push_str(&tracked_lines.to_string()); - out.push_str(""); + out.push_str(""); out.push_str(&covered.to_string()); - out.push_str(""); + out.push_str(""); out.push_str(&partial.to_string()); - out.push_str(""); + out.push_str(""); out.push_str(&missed.to_string()); + // Hazards mode columns + out.push_str(""); + out.push_str(&hazards.to_string()); + out.push_str(""); + out.push_str(&covered_hazards.to_string()); + out.push_str(""); + out.push_str(&evidence_covered_hazards.to_string()); + // Coverage bar td out.push_str(""); + out.push_str("
"); out.push_str(&render_line_quality_bar(LineQualityBar { tracked_lines, covered_lines, @@ -5963,8 +6236,25 @@ fn render_coverage_table_row( mutant_backed_lines, coverage_percent: percent_value, })); - out.push_str("
"); + out.push_str(""); + out.push_str("
"); + out.push_str(&render_hazard_quality_bar( + hazards, + covered_hazards, + evidence_covered_hazards, + )); + out.push_str("
"); + // Percentage tds (cov-col and haz-col) + let cov_color = percent_color_class(percent_value); + let haz_color = percent_color_class(hazard_percent_value); + out.push_str("
"); out.push_str(&format!("{percent_value:.2}%")); + out.push_str(""); + out.push_str(&format!("{hazard_percent_value:.2}%")); out.push_str("
ALTER COLUMN SET NOT NULL;` +> - **MySQL / MariaDB**: `ALTER TABLE
MODIFY NOT NULL;` +> - **SQLite**: SQLite does not support direct column alteration; you must rebuild the table structure or use a temporary table rename sequence. + +## CLI Parameters + +Parameters passed to `--param` must be typed to ensure correct query binding: +- Primitives: `int:42`, `float:1.5`, `bool:true`, `text:value` +- Nulls: `null:int`, `null:float`, `null:bool`, `null:text` +- Bare values are treated as text by default for backwards compatibility. + +## Outputs + +SQL-COV generates outputs matching standard coverage and static analysis specifications: + +- **JSON**: Comprehensive report containing byte/line/column spans, raw hit counts, and nullability profiles for downstream ingestion. +- **LCOV**: Emits standard `BRDA` branch records, enabling integration with visualization tools like Codecov, Coveralls, or local HTML frontends. +- **HTML**: Self-contained, premium interactive report highlighting SQL syntax, branch outcomes (True/False/Unknown), and coverage warnings. +- **SARIF**: Emits SARIF 2.1.0 records representing static hazard findings for GitHub Code Scanning and IDE gutter integrations. + +## Supported Dialects Roadmap + +SQL-COV leverages dialect-specific frontends and live schema catalogs for exact type and nullability resolution. + +- [x] **SQLite**: Fully supported (in-memory and file databases). +- [x] **PostgreSQL**: Fully supported (via connection string). +- [x] **MySQL / MariaDB**: Fully supported (via connection string). + +## Boundaries + +SQL-COV does not: +- Rewrite production queries; +- Prove that a query is optimal (e.g. index selection or CPU cost); +- Inject runtime instrumentation inside production application binaries; +- Execute without a mock/test database connection when running telemetry. + +It resolves SQL branch correctness and maps condition domains. + +## CI Integration + +SQL-COV is fully integrated into this project's GitHub Actions workflow to scan for SQL hazards and three-valued logic bugs automatically. + +### Workflow Configuration + +The scan is executed in the `generalized-gems-sarif` job within [.github/workflows/ci.yml](../../.github/workflows/ci.yml): + +```yaml + - name: Build Rust binaries + run: | + cargo build --release --manifest-path gems/sql-cov/Cargo.toml + + - name: Generate sql-cov SARIF + run: | + ./tools/generate_sql_cov_sarif.rb \ + --repo=. \ + --out-dir=tmp/generalized-gems-sarif \ + --setup=gems/lineage/sql/storage/init_schema.sql \ + --sql-cov-bin=./gems/sql-cov/target/release/sql-cov + + - name: Upload SQL-cov SARIF + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: tmp/generalized-gems-sarif/sql-cov.sarif + category: sql-cov +``` + +The resulting SARIF report is uploaded directly to GitHub Code Scanning to present alerts on PRs. + +## Links + +- [CLEAR compiler](../../README.md) +- [Lineage](../lineage/README.md): CLEAR's verification and LLM review UI which ingests SQL-COV coverage records. +- [SlopCop](../slopcop/README.md): Categorizes uncovered branches and ranks true test gaps. diff --git a/gems/sql-cov/coverage.lcov b/gems/sql-cov/coverage.lcov new file mode 100644 index 000000000..d31ea9934 --- /dev/null +++ b/gems/sql-cov/coverage.lcov @@ -0,0 +1,2865 @@ +SF:/home/yahn/easy-vm/gems/sql-cov/src/driver.rs +FN:24,_RNvMNtCs7hk0fIitb2P_7sql_cov6driverNtB2_14ParameterValue5parse +FN:286,_RNvNtCs7hk0fIitb2P_7sql_cov6driver10bind_mysql +FN:72,_RNvNtCs7hk0fIitb2P_7sql_cov6driver10mysql_pool +FN:254,_RNvNtCs7hk0fIitb2P_7sql_cov6driver11bind_sqlite +FN:185,_RNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql +FN:54,_RNvNtCs7hk0fIitb2P_7sql_cov6driver11sqlite_pool +FN:104,_RNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite +FN:270,_RNvNtCs7hk0fIitb2P_7sql_cov6driver13bind_postgres +FN:64,_RNvNtCs7hk0fIitb2P_7sql_cov6driver13postgres_pool +FN:150,_RNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres +FN:96,_RNvNtCs7hk0fIitb2P_7sql_cov6driver19execute_mysql_setup +FN:80,_RNvNtCs7hk0fIitb2P_7sql_cov6driver20execute_sqlite_setup +FN:88,_RNvNtCs7hk0fIitb2P_7sql_cov6driver22execute_postgres_setup +FN:230,_RINvNtCs7hk0fIitb2P_7sql_cov6driver19collect_i64_metricsNtNtCs7BTHx1qWKiX_13sqlx_postgres3row5PgRowEB4_ +FN:192,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql00B7_ +FN:211,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql0s0_0B7_ +FN:217,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql0s1_0B7_ +FN:203,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql0s_0B7_ +FN:111,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite00B7_ +FN:133,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite0s0_0B7_ +FN:122,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite0s_0B7_ +FN:157,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres00B7_ +FN:179,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres0s0_0B7_ +FN:168,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres0s_0B7_ +FN:72,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver10mysql_pool0B5_ +FN:189,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql0B5_ +FN:54,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver11sqlite_pool0B5_ +FN:108,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite0B5_ +FN:64,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver13postgres_pool0B5_ +FN:154,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres0B5_ +FN:96,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver19execute_mysql_setup0B5_ +FN:80,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver20execute_sqlite_setup0B5_ +FN:88,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver22execute_postgres_setup0B5_ +FN:72,_RNvNtCs8VZTiruxtkl_7sql_cov6driver10mysql_pool +FN:185,_RNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql +FN:54,_RNvNtCs8VZTiruxtkl_7sql_cov6driver11sqlite_pool +FN:104,_RNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite +FN:64,_RNvNtCs8VZTiruxtkl_7sql_cov6driver13postgres_pool +FN:150,_RNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres +FN:96,_RNvNtCs8VZTiruxtkl_7sql_cov6driver19execute_mysql_setup +FN:80,_RNvNtCs8VZTiruxtkl_7sql_cov6driver20execute_sqlite_setup +FN:88,_RNvNtCs8VZTiruxtkl_7sql_cov6driver22execute_postgres_setup +FN:325,_RNvNtNtCs8VZTiruxtkl_7sql_cov6driver5testss_28test_bind_mysql_all_variants +FN:307,_RNvNtNtCs8VZTiruxtkl_7sql_cov6driver5testss_31test_bind_postgres_all_variants +FN:230,_RINvNtCs8VZTiruxtkl_7sql_cov6driver19collect_i64_metricsNtNtCs7BTHx1qWKiX_13sqlx_postgres3row5PgRowEB4_ +FN:192,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql00B7_ +FN:211,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql0s0_0B7_ +FN:217,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql0s1_0B7_ +FN:203,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql0s_0B7_ +FN:111,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite00B7_ +FN:133,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite0s0_0B7_ +FN:122,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite0s_0B7_ +FN:157,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres00B7_ +FN:179,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres0s0_0B7_ +FN:168,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres0s_0B7_ +FN:72,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver10mysql_pool0B5_ +FN:189,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql0B5_ +FN:54,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver11sqlite_pool0B5_ +FN:108,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite0B5_ +FN:64,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver13postgres_pool0B5_ +FN:154,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres0B5_ +FN:96,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver19execute_mysql_setup0B5_ +FN:80,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver20execute_sqlite_setup0B5_ +FN:88,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver22execute_postgres_setup0B5_ +FN:24,_RNvMNtCs8VZTiruxtkl_7sql_cov6driverNtB2_14ParameterValue5parse +FN:286,_RNvNtCs8VZTiruxtkl_7sql_cov6driver10bind_mysql +FN:254,_RNvNtCs8VZTiruxtkl_7sql_cov6driver11bind_sqlite +FN:270,_RNvNtCs8VZTiruxtkl_7sql_cov6driver13bind_postgres +FNDA:120,_RNvMNtCs7hk0fIitb2P_7sql_cov6driverNtB2_14ParameterValue5parse +FNDA:8,_RNvNtCs7hk0fIitb2P_7sql_cov6driver10bind_mysql +FNDA:3,_RNvNtCs7hk0fIitb2P_7sql_cov6driver10mysql_pool +FNDA:131,_RNvNtCs7hk0fIitb2P_7sql_cov6driver11bind_sqlite +FNDA:1,_RNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql +FNDA:32,_RNvNtCs7hk0fIitb2P_7sql_cov6driver11sqlite_pool +FNDA:19,_RNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite +FNDA:8,_RNvNtCs7hk0fIitb2P_7sql_cov6driver13bind_postgres +FNDA:3,_RNvNtCs7hk0fIitb2P_7sql_cov6driver13postgres_pool +FNDA:1,_RNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres +FNDA:0,_RNvNtCs7hk0fIitb2P_7sql_cov6driver19execute_mysql_setup +FNDA:31,_RNvNtCs7hk0fIitb2P_7sql_cov6driver20execute_sqlite_setup +FNDA:0,_RNvNtCs7hk0fIitb2P_7sql_cov6driver22execute_postgres_setup +FNDA:0,_RINvNtCs7hk0fIitb2P_7sql_cov6driver19collect_i64_metricsNtNtCs7BTHx1qWKiX_13sqlx_postgres3row5PgRowEB4_ +FNDA:8,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql00B7_ +FNDA:0,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql0s0_0B7_ +FNDA:0,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql0s1_0B7_ +FNDA:1,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql0s_0B7_ +FNDA:72,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite00B7_ +FNDA:0,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite0s0_0B7_ +FNDA:0,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite0s_0B7_ +FNDA:8,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres00B7_ +FNDA:0,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres0s0_0B7_ +FNDA:1,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres0s_0B7_ +FNDA:3,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver10mysql_pool0B5_ +FNDA:1,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver11cover_mysql0B5_ +FNDA:32,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver11sqlite_pool0B5_ +FNDA:19,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver12cover_sqlite0B5_ +FNDA:3,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver13postgres_pool0B5_ +FNDA:1,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver14cover_postgres0B5_ +FNDA:0,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver19execute_mysql_setup0B5_ +FNDA:31,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver20execute_sqlite_setup0B5_ +FNDA:0,_RNCNvNtCs7hk0fIitb2P_7sql_cov6driver22execute_postgres_setup0B5_ +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver10mysql_pool +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver11sqlite_pool +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver13postgres_pool +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver19execute_mysql_setup +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver20execute_sqlite_setup +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver22execute_postgres_setup +FNDA:1,_RNvNtNtCs8VZTiruxtkl_7sql_cov6driver5testss_28test_bind_mysql_all_variants +FNDA:1,_RNvNtNtCs8VZTiruxtkl_7sql_cov6driver5testss_31test_bind_postgres_all_variants +FNDA:0,_RINvNtCs8VZTiruxtkl_7sql_cov6driver19collect_i64_metricsNtNtCs7BTHx1qWKiX_13sqlx_postgres3row5PgRowEB4_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql00B7_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql0s0_0B7_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql0s1_0B7_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql0s_0B7_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite00B7_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite0s0_0B7_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite0s_0B7_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres00B7_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres0s0_0B7_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres0s_0B7_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver10mysql_pool0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver11cover_mysql0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver11sqlite_pool0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver12cover_sqlite0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver13postgres_pool0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver14cover_postgres0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver19execute_mysql_setup0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver20execute_sqlite_setup0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6driver22execute_postgres_setup0B5_ +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov6driverNtB2_14ParameterValue5parse +FNDA:8,_RNvNtCs8VZTiruxtkl_7sql_cov6driver10bind_mysql +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6driver11bind_sqlite +FNDA:8,_RNvNtCs8VZTiruxtkl_7sql_cov6driver13bind_postgres +FNF:35 +FNH:25 +DA:24,120 +DA:25,120 +DA:26,8 +DA:27,112 +DA:28,112 +DA:29,6 +DA:30,106 +DA:31,106 +DA:32,4 +DA:33,102 +DA:34,102 +DA:35,6 +DA:36,96 +DA:37,96 +DA:38,28 +DA:39,68 +DA:40,68 +DA:41,11 +DA:42,57 +DA:43,57 +DA:45,8 +DA:47,49 +DA:48,49 +DA:49,49 +DA:50,49 +DA:51,120 +DA:54,32 +DA:55,32 +DA:56,32 +DA:57,32 +DA:58,32 +DA:59,32 +DA:60,32 +DA:61,32 +DA:62,32 +DA:64,3 +DA:65,3 +DA:66,3 +DA:67,3 +DA:68,3 +DA:69,3 +DA:70,3 +DA:72,3 +DA:73,3 +DA:74,3 +DA:75,3 +DA:76,3 +DA:77,3 +DA:78,3 +DA:80,31 +DA:81,31 +DA:82,31 +DA:83,31 +DA:84,31 +DA:85,31 +DA:86,31 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:93,0 +DA:94,0 +DA:96,0 +DA:97,0 +DA:98,0 +DA:99,0 +DA:100,0 +DA:101,0 +DA:102,0 +DA:104,19 +DA:105,19 +DA:106,19 +DA:107,19 +DA:108,19 +DA:109,19 +DA:110,19 +DA:111,72 +DA:112,19 +DA:113,19 +DA:114,19 +DA:115,19 +DA:116,72 +DA:117,72 +DA:118,72 +DA:119,19 +DA:120,19 +DA:121,19 +DA:122,19 +DA:123,19 +DA:125,29 +DA:126,29 +DA:127,59 +DA:128,59 +DA:129,59 +DA:130,29 +DA:131,29 +DA:132,29 +DA:133,29 +DA:134,77 +DA:135,77 +DA:136,77 +DA:137,77 +DA:138,77 +DA:139,77 +DA:140,77 +DA:141,77 +DA:142,77 +DA:143,77 +DA:144,77 +DA:147,19 +DA:148,19 +DA:150,1 +DA:151,1 +DA:152,1 +DA:153,1 +DA:154,1 +DA:155,1 +DA:156,1 +DA:157,8 +DA:158,1 +DA:159,1 +DA:160,1 +DA:161,1 +DA:162,8 +DA:163,8 +DA:164,8 +DA:165,1 +DA:166,1 +DA:167,1 +DA:168,1 +DA:169,0 +DA:171,0 +DA:172,0 +DA:173,0 +DA:174,0 +DA:175,0 +DA:176,0 +DA:177,0 +DA:178,0 +DA:179,0 +DA:180,0 +DA:182,0 +DA:183,1 +DA:185,1 +DA:186,1 +DA:187,1 +DA:188,1 +DA:189,1 +DA:190,1 +DA:191,1 +DA:192,8 +DA:193,1 +DA:194,1 +DA:195,1 +DA:196,1 +DA:197,8 +DA:198,8 +DA:199,8 +DA:200,1 +DA:201,1 +DA:202,1 +DA:203,1 +DA:204,0 +DA:206,0 +DA:207,0 +DA:208,0 +DA:209,0 +DA:210,0 +DA:211,0 +DA:212,0 +DA:214,0 +DA:215,0 +DA:216,0 +DA:217,0 +DA:218,0 +DA:219,0 +DA:220,0 +DA:222,0 +DA:224,0 +DA:227,0 +DA:228,1 +DA:230,0 +DA:231,0 +DA:232,0 +DA:233,0 +DA:234,0 +DA:235,0 +DA:236,0 +DA:237,0 +DA:239,0 +DA:240,0 +DA:241,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:251,0 +DA:252,0 +DA:254,131 +DA:255,131 +DA:256,131 +DA:257,131 +DA:258,131 +DA:259,88 +DA:260,35 +DA:261,5 +DA:262,0 +DA:263,3 +DA:264,0 +DA:265,0 +DA:266,0 +DA:268,131 +DA:270,16 +DA:271,16 +DA:272,16 +DA:273,16 +DA:274,16 +DA:275,2 +DA:276,2 +DA:277,2 +DA:278,2 +DA:279,2 +DA:280,2 +DA:281,2 +DA:282,2 +DA:284,16 +DA:286,16 +DA:287,16 +DA:288,16 +DA:289,16 +DA:290,16 +DA:291,2 +DA:292,2 +DA:293,2 +DA:294,2 +DA:295,2 +DA:296,2 +DA:297,2 +DA:298,2 +DA:300,16 +DA:307,1 +DA:308,1 +DA:309,1 +DA:310,1 +DA:311,1 +DA:312,1 +DA:313,1 +DA:314,1 +DA:315,1 +DA:316,1 +DA:318,8 +DA:319,8 +DA:320,8 +DA:321,8 +DA:322,1 +DA:325,1 +DA:326,1 +DA:327,1 +DA:328,1 +DA:329,1 +DA:330,1 +DA:331,1 +DA:332,1 +DA:333,1 +DA:334,1 +DA:336,8 +DA:337,8 +DA:338,8 +DA:339,8 +DA:340,1 +BRF:0 +BRH:0 +LF:294 +LH:218 +end_of_record +SF:/home/yahn/easy-vm/gems/sql-cov/src/hazard.rs +FN:314,_RNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB5_13HazardVisitor14push_if_proven +FN:195,_RNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB5_13HazardVisitor19detect_unknown_trap +FN:290,_RNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB5_13HazardVisitor27detect_outer_join_rejection +FN:131,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard10scan_query +FN:428,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard11hazard_span +FN:371,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard14scan_join_keys +FN:61,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard15analyze_hazards +FN:447,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard15location_offset +FN:346,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard26quantified_rhs_nullability +FN:359,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard27subquery_output_nullability +FN:125,_RNvXNtCs7hk0fIitb2P_7sql_cov6hazardNtB2_14QueryCollectorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor15pre_visit_query +FN:387,_RNvXNvNtCs7hk0fIitb2P_7sql_cov6hazard14scan_join_keysNtB2_11JoinVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:187,_RNvXs_NtCs7hk0fIitb2P_7sql_cov6hazardNtB4_13HazardVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:216,_RNCNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB7_13HazardVisitor19detect_unknown_trap0B9_ +FN:297,_RNCNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB7_13HazardVisitor27detect_outer_join_rejection0B9_ +FN:73,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard15analyze_hazards0B5_ +FN:89,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard15analyze_hazardss0_0B5_ +FN:88,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard15analyze_hazardss_0B5_ +FN:461,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard15location_offsets_0B5_ +FN:352,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard26quantified_rhs_nullability0B5_ +FN:216,_RNCNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB7_13HazardVisitor19detect_unknown_trap0B9_ +FN:297,_RNCNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB7_13HazardVisitor27detect_outer_join_rejection0B9_ +FN:73,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard15analyze_hazards0B5_ +FN:89,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard15analyze_hazardss0_0B5_ +FN:88,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard15analyze_hazardss_0B5_ +FN:461,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard15location_offsets_0B5_ +FN:352,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard26quantified_rhs_nullability0B5_ +FN:314,_RNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB5_13HazardVisitor14push_if_proven +FN:195,_RNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB5_13HazardVisitor19detect_unknown_trap +FN:290,_RNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB5_13HazardVisitor27detect_outer_join_rejection +FN:131,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard10scan_query +FN:428,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard11hazard_span +FN:371,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard14scan_join_keys +FN:61,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard15analyze_hazards +FN:447,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard15location_offset +FN:346,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard26quantified_rhs_nullability +FN:359,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard27subquery_output_nullability +FN:125,_RNvXNtCs8VZTiruxtkl_7sql_cov6hazardNtB2_14QueryCollectorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor15pre_visit_query +FN:387,_RNvXNvNtCs8VZTiruxtkl_7sql_cov6hazard14scan_join_keysNtB2_11JoinVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:187,_RNvXs_NtCs8VZTiruxtkl_7sql_cov6hazardNtB4_13HazardVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:120,_RNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB5_13HazardVisitor14push_if_proven +FNDA:524,_RNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB5_13HazardVisitor19detect_unknown_trap +FNDA:524,_RNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB5_13HazardVisitor27detect_outer_join_rejection +FNDA:189,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard10scan_query +FNDA:73,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard11hazard_span +FNDA:3,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard14scan_join_keys +FNDA:117,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard15analyze_hazards +FNDA:146,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard15location_offset +FNDA:106,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard26quantified_rhs_nullability +FNDA:67,_RNvNtCs7hk0fIitb2P_7sql_cov6hazard27subquery_output_nullability +FNDA:189,_RNvXNtCs7hk0fIitb2P_7sql_cov6hazardNtB2_14QueryCollectorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor15pre_visit_query +FNDA:9,_RNvXNvNtCs7hk0fIitb2P_7sql_cov6hazard14scan_join_keysNtB2_11JoinVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:524,_RNvXs_NtCs7hk0fIitb2P_7sql_cov6hazardNtB4_13HazardVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:6,_RNCNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB7_13HazardVisitor19detect_unknown_trap0B9_ +FNDA:3,_RNCNvMs0_NtCs7hk0fIitb2P_7sql_cov6hazardNtB7_13HazardVisitor27detect_outer_join_rejection0B9_ +FNDA:0,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard15analyze_hazards0B5_ +FNDA:5,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard15analyze_hazardss0_0B5_ +FNDA:10,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard15analyze_hazardss_0B5_ +FNDA:4,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard15location_offsets_0B5_ +FNDA:77,_RNCNvNtCs7hk0fIitb2P_7sql_cov6hazard26quantified_rhs_nullability0B5_ +FNDA:0,_RNCNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB7_13HazardVisitor19detect_unknown_trap0B9_ +FNDA:0,_RNCNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB7_13HazardVisitor27detect_outer_join_rejection0B9_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard15analyze_hazards0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard15analyze_hazardss0_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard15analyze_hazardss_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard15location_offsets_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6hazard26quantified_rhs_nullability0B5_ +FNDA:0,_RNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB5_13HazardVisitor14push_if_proven +FNDA:0,_RNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB5_13HazardVisitor19detect_unknown_trap +FNDA:0,_RNvMs0_NtCs8VZTiruxtkl_7sql_cov6hazardNtB5_13HazardVisitor27detect_outer_join_rejection +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard10scan_query +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard11hazard_span +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard14scan_join_keys +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard15analyze_hazards +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard15location_offset +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard26quantified_rhs_nullability +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6hazard27subquery_output_nullability +FNDA:0,_RNvXNtCs8VZTiruxtkl_7sql_cov6hazardNtB2_14QueryCollectorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor15pre_visit_query +FNDA:0,_RNvXNvNtCs8VZTiruxtkl_7sql_cov6hazard14scan_join_keysNtB2_11JoinVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:0,_RNvXs_NtCs8VZTiruxtkl_7sql_cov6hazardNtB4_13HazardVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNF:20 +FNH:19 +DA:61,117 +DA:62,117 +DA:63,117 +DA:64,117 +DA:65,117 +DA:66,117 +DA:67,117 +DA:68,11 +DA:69,64 +DA:70,42 +DA:72,117 +DA:73,117 +DA:74,117 +DA:75,117 +DA:77,122 +DA:78,122 +DA:79,0 +DA:81,122 +DA:82,122 +DA:83,189 +DA:84,189 +DA:85,189 +DA:88,117 +DA:89,117 +DA:90,5 +DA:91,1 +DA:92,0 +DA:93,5 +DA:97,117 +DA:98,73 +DA:99,73 +DA:100,73 +DA:101,73 +DA:102,73 +DA:103,73 +DA:104,73 +DA:106,117 +DA:107,117 +DA:108,117 +DA:109,117 +DA:110,117 +DA:111,117 +DA:112,117 +DA:113,117 +DA:114,117 +DA:115,117 +DA:125,189 +DA:126,189 +DA:127,189 +DA:128,189 +DA:131,189 +DA:132,189 +DA:133,189 +DA:134,189 +DA:135,189 +DA:136,189 +DA:137,189 +DA:138,189 +DA:139,0 +DA:141,189 +DA:142,189 +DA:143,121 +DA:144,121 +DA:145,121 +DA:146,121 +DA:147,121 +DA:148,121 +DA:149,121 +DA:150,121 +DA:151,121 +DA:152,121 +DA:153,121 +DA:154,189 +DA:155,1 +DA:156,1 +DA:157,1 +DA:158,1 +DA:159,1 +DA:160,1 +DA:161,1 +DA:162,1 +DA:163,1 +DA:164,1 +DA:165,188 +DA:166,189 +DA:167,189 +DA:168,3 +DA:169,3 +DA:170,3 +DA:173,189 +DA:187,524 +DA:188,524 +DA:189,524 +DA:190,524 +DA:191,524 +DA:195,524 +DA:196,524 +DA:198,6 +DA:200,6 +DA:201,6 +DA:202,6 +DA:203,6 +DA:204,6 +DA:205,6 +DA:206,6 +DA:207,6 +DA:208,6 +DA:210,3 +DA:211,3 +DA:214,3 +DA:215,3 +DA:216,6 +DA:218,3 +DA:219,3 +DA:220,3 +DA:221,3 +DA:222,3 +DA:223,3 +DA:224,3 +DA:227,1 +DA:229,1 +DA:230,1 +DA:231,1 +DA:232,1 +DA:233,1 +DA:234,1 +DA:235,1 +DA:236,1 +DA:237,1 +DA:239,0 +DA:242,0 +DA:243,0 +DA:244,0 +DA:245,0 +DA:246,0 +DA:247,0 +DA:248,0 +DA:249,0 +DA:250,0 +DA:252,1 +DA:254,1 +DA:255,1 +DA:256,1 +DA:257,1 +DA:258,1 +DA:259,1 +DA:260,1 +DA:261,1 +DA:262,1 +DA:263,1 +DA:264,1 +DA:267,1 +DA:268,1 +DA:269,1 +DA:270,1 +DA:271,1 +DA:272,1 +DA:273,1 +DA:274,1 +DA:275,106 +DA:276,106 +DA:277,106 +DA:278,106 +DA:279,106 +DA:280,106 +DA:281,106 +DA:282,106 +DA:283,406 +DA:285,524 +DA:286,118 +DA:287,406 +DA:288,524 +DA:290,524 +DA:291,524 +DA:292,508 +DA:293,16 +DA:294,16 +DA:295,16 +DA:296,16 +DA:297,16 +DA:298,16 +DA:299,16 +DA:300,16 +DA:301,14 +DA:302,2 +DA:303,2 +DA:304,2 +DA:305,2 +DA:306,2 +DA:307,2 +DA:308,2 +DA:309,2 +DA:310,2 +DA:312,524 +DA:314,120 +DA:315,120 +DA:316,120 +DA:317,120 +DA:318,120 +DA:319,120 +DA:320,120 +DA:321,120 +DA:322,120 +DA:323,120 +DA:324,47 +DA:326,3 +DA:327,3 +DA:329,70 +DA:331,70 +DA:332,0 +DA:334,70 +DA:335,70 +DA:336,70 +DA:337,70 +DA:338,70 +DA:339,70 +DA:340,70 +DA:341,70 +DA:342,70 +DA:343,120 +DA:346,106 +DA:347,106 +DA:348,66 +DA:349,38 +DA:350,38 +DA:351,38 +DA:352,77 +DA:353,77 +DA:354,77 +DA:355,2 +DA:357,106 +DA:359,67 +DA:360,67 +DA:361,0 +DA:363,67 +DA:364,67 +DA:365,67 +DA:366,0 +DA:367,0 +DA:369,67 +DA:371,3 +DA:372,3 +DA:373,3 +DA:374,3 +DA:375,3 +DA:376,3 +DA:377,3 +DA:387,9 +DA:388,9 +DA:389,6 +DA:391,3 +DA:392,0 +DA:393,3 +DA:394,3 +DA:395,3 +DA:396,3 +DA:399,3 +DA:400,0 +DA:402,3 +DA:403,3 +DA:404,3 +DA:405,3 +DA:406,3 +DA:407,3 +DA:408,3 +DA:409,3 +DA:410,3 +DA:411,3 +DA:412,3 +DA:414,0 +DA:416,3 +DA:417,9 +DA:419,3 +DA:420,3 +DA:421,3 +DA:422,3 +DA:423,3 +DA:424,3 +DA:425,3 +DA:426,3 +DA:428,73 +DA:429,73 +DA:430,73 +DA:431,73 +DA:432,73 +DA:433,73 +DA:434,73 +DA:435,73 +DA:437,73 +DA:438,73 +DA:439,73 +DA:440,73 +DA:441,73 +DA:442,73 +DA:443,73 +DA:445,73 +DA:447,146 +DA:448,146 +DA:449,0 +DA:450,146 +DA:451,146 +DA:452,146 +DA:453,146 +DA:454,146 +DA:455,146 +DA:456,146 +DA:457,146 +DA:458,146 +DA:459,146 +DA:460,146 +DA:461,146 +DA:462,146 +DA:463,146 +BRF:0 +BRH:0 +LF:319 +LH:297 +end_of_record +SF:/home/yahn/easy-vm/gems/sql-cov/src/instrument.rs +FN:18,_RNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_query +FN:10,_RNvNtCs7hk0fIitb2P_7sql_cov10instrument17telemetry_queries +FN:30,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_query00B7_ +FN:25,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_query0B5_ +FN:39,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_querys0_0B5_ +FN:49,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_querys1_0B5_ +FN:35,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_querys_0B5_ +FN:14,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument17telemetry_queries0B5_ +FN:18,_RNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_query +FN:10,_RNvNtCs8VZTiruxtkl_7sql_cov10instrument17telemetry_queries +FN:30,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_query00B7_ +FN:25,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_query0B5_ +FN:39,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_querys0_0B5_ +FN:49,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_querys1_0B5_ +FN:35,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_querys_0B5_ +FN:14,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument17telemetry_queries0B5_ +FNDA:30,_RNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_query +FNDA:20,_RNvNtCs7hk0fIitb2P_7sql_cov10instrument17telemetry_queries +FNDA:80,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_query00B7_ +FNDA:80,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_query0B5_ +FNDA:80,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_querys0_0B5_ +FNDA:80,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_querys1_0B5_ +FNDA:80,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument15telemetry_querys_0B5_ +FNDA:30,_RNCNvNtCs7hk0fIitb2P_7sql_cov10instrument17telemetry_queries0B5_ +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_query +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov10instrument17telemetry_queries +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_query00B7_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_query0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_querys0_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_querys1_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument15telemetry_querys_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov10instrument17telemetry_queries0B5_ +FNF:8 +FNH:8 +DA:10,20 +DA:11,20 +DA:12,20 +DA:13,20 +DA:14,30 +DA:15,20 +DA:16,20 +DA:18,30 +DA:19,30 +DA:20,0 +DA:21,30 +DA:22,30 +DA:23,30 +DA:24,30 +DA:25,80 +DA:26,80 +DA:27,80 +DA:28,80 +DA:29,80 +DA:30,80 +DA:31,80 +DA:32,30 +DA:33,30 +DA:34,30 +DA:35,80 +DA:36,30 +DA:37,30 +DA:38,30 +DA:39,80 +DA:40,80 +DA:41,80 +DA:42,80 +DA:43,80 +DA:44,80 +DA:45,80 +DA:46,30 +DA:47,30 +DA:48,30 +DA:49,80 +DA:50,30 +DA:51,30 +DA:52,0 +DA:54,30 +DA:56,30 +DA:57,28 +DA:59,2 +DA:61,30 +DA:62,30 +DA:63,30 +DA:64,30 +DA:65,30 +DA:66,30 +DA:67,30 +DA:68,30 +DA:69,30 +DA:70,30 +DA:71,30 +BRF:0 +BRH:0 +LF:63 +LH:61 +end_of_record +SF:/home/yahn/easy-vm/gems/sql-cov/src/main.rs +FN:281,_RNCNCNvCsbkSfK6g54mB_7sql_cov4main00B5_ +FN:349,_RNCNvCsbkSfK6g54mB_7sql_cov12write_output0B3_ +FN:83,_RNCNvCsbkSfK6g54mB_7sql_cov4main0B3_ +FN:347,_RNvCsbkSfK6g54mB_7sql_cov12write_output +FN:83,_RNvCsbkSfK6g54mB_7sql_cov4main +FN:338,_RNvCsbkSfK6g54mB_7sql_cov6render +FN:347,_RNvCs9aIdCWK8beB_7sql_cov12write_output +FN:83,_RNvCs9aIdCWK8beB_7sql_cov4main +FN:338,_RNvCs9aIdCWK8beB_7sql_cov6render +FN:281,_RNCNCNvCs9aIdCWK8beB_7sql_cov4main00B5_ +FN:349,_RNCNvCs9aIdCWK8beB_7sql_cov12write_output0B3_ +FN:83,_RNCNvCs9aIdCWK8beB_7sql_cov4main0B3_ +FNDA:0,_RNCNCNvCsbkSfK6g54mB_7sql_cov4main00B5_ +FNDA:0,_RNCNvCsbkSfK6g54mB_7sql_cov12write_output0B3_ +FNDA:0,_RNCNvCsbkSfK6g54mB_7sql_cov4main0B3_ +FNDA:0,_RNvCsbkSfK6g54mB_7sql_cov12write_output +FNDA:0,_RNvCsbkSfK6g54mB_7sql_cov4main +FNDA:0,_RNvCsbkSfK6g54mB_7sql_cov6render +FNDA:4,_RNvCs9aIdCWK8beB_7sql_cov12write_output +FNDA:11,_RNvCs9aIdCWK8beB_7sql_cov4main +FNDA:4,_RNvCs9aIdCWK8beB_7sql_cov6render +FNDA:0,_RNCNCNvCs9aIdCWK8beB_7sql_cov4main00B5_ +FNDA:0,_RNCNvCs9aIdCWK8beB_7sql_cov12write_output0B3_ +FNDA:11,_RNCNvCs9aIdCWK8beB_7sql_cov4main0B3_ +FNF:6 +FNH:4 +DA:83,11 +DA:84,11 +DA:85,11 +DA:86,11 +DA:87,11 +DA:88,11 +DA:89,11 +DA:90,11 +DA:91,11 +DA:92,11 +DA:93,11 +DA:94,11 +DA:95,11 +DA:96,11 +DA:97,11 +DA:98,11 +DA:99,11 +DA:100,11 +DA:101,11 +DA:102,11 +DA:103,11 +DA:104,11 +DA:105,11 +DA:106,11 +DA:107,11 +DA:108,11 +DA:109,11 +DA:110,11 +DA:111,11 +DA:112,11 +DA:113,11 +DA:114,11 +DA:115,11 +DA:116,11 +DA:117,11 +DA:118,11 +DA:119,11 +DA:120,11 +DA:121,11 +DA:122,11 +DA:123,11 +DA:124,11 +DA:125,11 +DA:126,11 +DA:127,11 +DA:128,11 +DA:129,11 +DA:130,11 +DA:131,11 +DA:132,11 +DA:133,11 +DA:134,11 +DA:135,11 +DA:136,11 +DA:137,11 +DA:138,11 +DA:139,11 +DA:140,11 +DA:141,11 +DA:142,11 +DA:143,11 +DA:144,11 +DA:145,11 +DA:146,11 +DA:147,11 +DA:148,11 +DA:149,11 +DA:150,11 +DA:151,11 +DA:152,11 +DA:153,11 +DA:154,11 +DA:155,11 +DA:156,11 +DA:157,11 +DA:158,11 +DA:159,11 +DA:160,11 +DA:161,11 +DA:162,11 +DA:163,11 +DA:164,11 +DA:165,11 +DA:166,11 +DA:167,11 +DA:168,11 +DA:169,11 +DA:170,11 +DA:171,11 +DA:172,11 +DA:173,11 +DA:174,11 +DA:175,11 +DA:176,11 +DA:177,11 +DA:178,11 +DA:179,11 +DA:180,11 +DA:181,11 +DA:182,11 +DA:183,11 +DA:184,11 +DA:185,11 +DA:186,11 +DA:187,11 +DA:188,11 +DA:189,11 +DA:190,11 +DA:191,11 +DA:192,11 +DA:193,11 +DA:194,11 +DA:195,11 +DA:196,11 +DA:197,11 +DA:198,11 +DA:199,11 +DA:200,11 +DA:201,0 +DA:202,0 +DA:203,11 +DA:204,11 +DA:205,11 +DA:206,11 +DA:207,11 +DA:208,11 +DA:209,11 +DA:210,11 +DA:211,11 +DA:212,11 +DA:213,11 +DA:214,11 +DA:215,11 +DA:216,11 +DA:217,11 +DA:218,11 +DA:219,11 +DA:220,11 +DA:221,11 +DA:222,11 +DA:223,11 +DA:224,11 +DA:225,11 +DA:226,11 +DA:227,0 +DA:228,0 +DA:229,0 +DA:230,11 +DA:231,0 +DA:232,0 +DA:233,11 +DA:234,11 +DA:235,0 +DA:236,0 +DA:237,11 +DA:238,11 +DA:239,11 +DA:240,11 +DA:241,11 +DA:242,11 +DA:243,11 +DA:244,11 +DA:245,11 +DA:246,11 +DA:247,11 +DA:248,11 +DA:249,11 +DA:250,11 +DA:251,11 +DA:252,11 +DA:253,11 +DA:254,11 +DA:255,11 +DA:256,11 +DA:257,11 +DA:258,11 +DA:259,11 +DA:260,11 +DA:261,11 +DA:262,11 +DA:263,11 +DA:264,11 +DA:265,11 +DA:266,11 +DA:267,11 +DA:268,11 +DA:269,11 +DA:270,11 +DA:271,11 +DA:272,11 +DA:273,11 +DA:274,11 +DA:275,11 +DA:276,11 +DA:277,11 +DA:278,11 +DA:279,11 +DA:280,11 +DA:281,11 +DA:282,11 +DA:283,11 +DA:284,11 +DA:285,11 +DA:286,11 +DA:287,11 +DA:288,11 +DA:289,11 +DA:290,0 +DA:291,0 +DA:292,0 +DA:293,0 +DA:294,11 +DA:295,11 +DA:296,11 +DA:297,0 +DA:298,0 +DA:299,0 +DA:300,0 +DA:301,11 +DA:302,11 +DA:303,11 +DA:304,11 +DA:305,11 +DA:306,11 +DA:307,11 +DA:308,11 +DA:309,11 +DA:310,11 +DA:311,11 +DA:312,11 +DA:313,11 +DA:314,11 +DA:315,11 +DA:316,11 +DA:317,11 +DA:318,11 +DA:319,11 +DA:320,0 +DA:321,0 +DA:322,0 +DA:323,0 +DA:324,0 +DA:325,0 +DA:326,0 +DA:327,0 +DA:328,0 +DA:329,0 +DA:330,0 +DA:331,0 +DA:332,0 +DA:333,11 +DA:334,11 +DA:335,11 +DA:336,11 +DA:338,4 +DA:339,4 +DA:340,4 +DA:341,2 +DA:342,1 +DA:343,1 +DA:345,4 +DA:347,4 +DA:348,4 +DA:349,3 +DA:350,1 +DA:351,1 +DA:352,1 +DA:353,4 +DA:354,4 +BRF:0 +BRH:0 +LF:470 +LH:334 +end_of_record +SF:/home/yahn/easy-vm/gems/sql-cov/src/model.rs +FN:54,_RNvMNtCs7hk0fIitb2P_7sql_cov5modelNtB2_14CoverageMetric12branch_count +FN:66,_RNvMNtCs7hk0fIitb2P_7sql_cov5modelNtB2_14CoverageMetric12is_uncovered +FN:62,_RNvMNtCs7hk0fIitb2P_7sql_cov5modelNtB2_14CoverageMetric16is_fully_covered +FN:48,_RNvMNtCs7hk0fIitb2P_7sql_cov5modelNtB2_14CoverageMetric20covered_branch_count +FN:91,_RNvMs_NtCs7hk0fIitb2P_7sql_cov5modelNtB4_18SourceFileCoverage14total_branches +FN:83,_RNvMs_NtCs7hk0fIitb2P_7sql_cov5modelNtB4_18SourceFileCoverage16covered_branches +FN:54,_RNvMNtCs8VZTiruxtkl_7sql_cov5modelNtB2_14CoverageMetric12branch_count +FN:66,_RNvMNtCs8VZTiruxtkl_7sql_cov5modelNtB2_14CoverageMetric12is_uncovered +FN:62,_RNvMNtCs8VZTiruxtkl_7sql_cov5modelNtB2_14CoverageMetric16is_fully_covered +FN:48,_RNvMNtCs8VZTiruxtkl_7sql_cov5modelNtB2_14CoverageMetric20covered_branch_count +FN:91,_RNvMs_NtCs8VZTiruxtkl_7sql_cov5modelNtB4_18SourceFileCoverage14total_branches +FN:83,_RNvMs_NtCs8VZTiruxtkl_7sql_cov5modelNtB4_18SourceFileCoverage16covered_branches +FNDA:54,_RNvMNtCs7hk0fIitb2P_7sql_cov5modelNtB2_14CoverageMetric12branch_count +FNDA:18,_RNvMNtCs7hk0fIitb2P_7sql_cov5modelNtB2_14CoverageMetric12is_uncovered +FNDA:20,_RNvMNtCs7hk0fIitb2P_7sql_cov5modelNtB2_14CoverageMetric16is_fully_covered +FNDA:45,_RNvMNtCs7hk0fIitb2P_7sql_cov5modelNtB2_14CoverageMetric20covered_branch_count +FNDA:6,_RNvMs_NtCs7hk0fIitb2P_7sql_cov5modelNtB4_18SourceFileCoverage14total_branches +FNDA:6,_RNvMs_NtCs7hk0fIitb2P_7sql_cov5modelNtB4_18SourceFileCoverage16covered_branches +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov5modelNtB2_14CoverageMetric12branch_count +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov5modelNtB2_14CoverageMetric12is_uncovered +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov5modelNtB2_14CoverageMetric16is_fully_covered +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov5modelNtB2_14CoverageMetric20covered_branch_count +FNDA:0,_RNvMs_NtCs8VZTiruxtkl_7sql_cov5modelNtB4_18SourceFileCoverage14total_branches +FNDA:0,_RNvMs_NtCs8VZTiruxtkl_7sql_cov5modelNtB4_18SourceFileCoverage16covered_branches +FNF:6 +FNH:6 +DA:48,45 +DA:49,45 +DA:50,45 +DA:51,45 +DA:52,45 +DA:54,54 +DA:55,54 +DA:56,51 +DA:58,3 +DA:60,54 +DA:62,20 +DA:63,20 +DA:64,20 +DA:66,18 +DA:67,18 +DA:68,18 +DA:83,6 +DA:84,6 +DA:85,6 +DA:86,6 +DA:87,6 +DA:88,6 +DA:89,6 +DA:91,6 +DA:92,6 +DA:93,6 +DA:94,6 +DA:95,6 +DA:96,6 +DA:97,6 +BRF:0 +BRH:0 +LF:30 +LH:30 +end_of_record +SF:/home/yahn/easy-vm/gems/sql-cov/src/nullability.rs +FN:44,_RNvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB2_19NullabilityEvidence5merge +FN:23,_RNvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB2_19NullabilityEvidence5never +FN:65,_RNvMs_NtCs7hk0fIitb2P_7sql_cov11nullabilityNtB4_8Resolver3new +FN:127,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability11nullability +FN:97,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability15register_factor +FN:269,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability17resolve_qualified +FN:356,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability18referenced_aliases +FN:301,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolve_unqualified +FN:74,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolver_for_select +FN:195,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability20coalesce_nullability +FN:380,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability27is_null_rejecting_predicate +FN:335,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability32explicitly_null_accepted_aliases +FN:401,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability9join_expr +FN:363,_RNvXNvNtCs7hk0fIitb2P_7sql_cov11nullability18referenced_aliasesNtB2_12AliasVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:342,_RNvXNvNtCs7hk0fIitb2P_7sql_cov11nullability32explicitly_null_accepted_aliasesNtB2_17NullPolicyVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:246,_RNvXs0_NtCs7hk0fIitb2P_7sql_cov11nullabilityNtB5_21NullableColumnVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:30,_RINvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB3_19NullabilityEvidence7unknownNtNtCsgW8esjfipvk_5alloc6string6StringEB5_ +FN:30,_RINvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB3_19NullabilityEvidence7unknownReEB5_ +FN:37,_RINvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB3_19NullabilityEvidence8nullableNtNtCsgW8esjfipvk_5alloc6string6StringEB5_ +FN:37,_RINvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB3_19NullabilityEvidence8nullableReEB5_ +FN:310,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolve_unqualified00B7_ +FN:165,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability11nullability0B5_ +FN:104,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability15register_factor0B5_ +FN:305,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolve_unqualified0B5_ +FN:320,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolve_unqualifieds_0B5_ +FN:206,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability20coalesce_nullability0B5_ +FN:226,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability20coalesce_nullabilitys0_0B5_ +FN:220,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability20coalesce_nullabilitys_0B5_ +FN:30,_RINvMNtCs8VZTiruxtkl_7sql_cov11nullabilityNtB3_19NullabilityEvidence7unknownpEB5_ +FN:37,_RINvMNtCs8VZTiruxtkl_7sql_cov11nullabilityNtB3_19NullabilityEvidence8nullablepEB5_ +FN:310,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolve_unqualified00B7_ +FN:165,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability11nullability0B5_ +FN:104,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability15register_factor0B5_ +FN:305,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolve_unqualified0B5_ +FN:320,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolve_unqualifieds_0B5_ +FN:206,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability20coalesce_nullability0B5_ +FN:226,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability20coalesce_nullabilitys0_0B5_ +FN:220,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability20coalesce_nullabilitys_0B5_ +FN:44,_RNvMNtCs8VZTiruxtkl_7sql_cov11nullabilityNtB2_19NullabilityEvidence5merge +FN:23,_RNvMNtCs8VZTiruxtkl_7sql_cov11nullabilityNtB2_19NullabilityEvidence5never +FN:65,_RNvMs_NtCs8VZTiruxtkl_7sql_cov11nullabilityNtB4_8Resolver3new +FN:127,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability11nullability +FN:97,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability15register_factor +FN:269,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability17resolve_qualified +FN:356,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability18referenced_aliases +FN:301,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolve_unqualified +FN:74,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolver_for_select +FN:195,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability20coalesce_nullability +FN:380,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability27is_null_rejecting_predicate +FN:335,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability32explicitly_null_accepted_aliases +FN:401,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability9join_expr +FN:363,_RNvXNvNtCs8VZTiruxtkl_7sql_cov11nullability18referenced_aliasesNtB2_12AliasVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:342,_RNvXNvNtCs8VZTiruxtkl_7sql_cov11nullability32explicitly_null_accepted_aliasesNtB2_17NullPolicyVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:246,_RNvXs0_NtCs8VZTiruxtkl_7sql_cov11nullabilityNtB5_21NullableColumnVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:452,_RNvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB2_19NullabilityEvidence5merge +FNDA:494,_RNvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB2_19NullabilityEvidence5never +FNDA:350,_RNvMs_NtCs7hk0fIitb2P_7sql_cov11nullabilityNtB4_8Resolver3new +FNDA:869,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability11nullability +FNDA:573,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability15register_factor +FNDA:212,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability17resolve_qualified +FNDA:599,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability18referenced_aliases +FNDA:237,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolve_unqualified +FNDA:256,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolver_for_select +FNDA:4,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability20coalesce_nullability +FNDA:524,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability27is_null_rejecting_predicate +FNDA:121,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability32explicitly_null_accepted_aliases +FNDA:3,_RNvNtCs7hk0fIitb2P_7sql_cov11nullability9join_expr +FNDA:3238,_RNvXNvNtCs7hk0fIitb2P_7sql_cov11nullability18referenced_aliasesNtB2_12AliasVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:521,_RNvXNvNtCs7hk0fIitb2P_7sql_cov11nullability32explicitly_null_accepted_aliasesNtB2_17NullPolicyVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:78,_RNvXs0_NtCs7hk0fIitb2P_7sql_cov11nullabilityNtB5_21NullableColumnVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:48,_RINvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB3_19NullabilityEvidence7unknownNtNtCsgW8esjfipvk_5alloc6string6StringEB5_ +FNDA:46,_RINvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB3_19NullabilityEvidence7unknownReEB5_ +FNDA:115,_RINvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB3_19NullabilityEvidence8nullableNtNtCsgW8esjfipvk_5alloc6string6StringEB5_ +FNDA:17,_RINvMNtCs7hk0fIitb2P_7sql_cov11nullabilityNtB3_19NullabilityEvidence8nullableReEB5_ +FNDA:217,_RNCNCNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolve_unqualified00B7_ +FNDA:4,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability11nullability0B5_ +FNDA:159,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability15register_factor0B5_ +FNDA:241,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolve_unqualified0B5_ +FNDA:217,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability19resolve_unqualifieds_0B5_ +FNDA:8,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability20coalesce_nullability0B5_ +FNDA:0,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability20coalesce_nullabilitys0_0B5_ +FNDA:8,_RNCNvNtCs7hk0fIitb2P_7sql_cov11nullability20coalesce_nullabilitys_0B5_ +FNDA:0,_RINvMNtCs8VZTiruxtkl_7sql_cov11nullabilityNtB3_19NullabilityEvidence7unknownpEB5_ +FNDA:0,_RINvMNtCs8VZTiruxtkl_7sql_cov11nullabilityNtB3_19NullabilityEvidence8nullablepEB5_ +FNDA:0,_RNCNCNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolve_unqualified00B7_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability11nullability0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability15register_factor0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolve_unqualified0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolve_unqualifieds_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability20coalesce_nullability0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability20coalesce_nullabilitys0_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov11nullability20coalesce_nullabilitys_0B5_ +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov11nullabilityNtB2_19NullabilityEvidence5merge +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov11nullabilityNtB2_19NullabilityEvidence5never +FNDA:0,_RNvMs_NtCs8VZTiruxtkl_7sql_cov11nullabilityNtB4_8Resolver3new +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability11nullability +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability15register_factor +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability17resolve_qualified +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability18referenced_aliases +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolve_unqualified +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability19resolver_for_select +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability20coalesce_nullability +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability27is_null_rejecting_predicate +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability32explicitly_null_accepted_aliases +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov11nullability9join_expr +FNDA:0,_RNvXNvNtCs8VZTiruxtkl_7sql_cov11nullability18referenced_aliasesNtB2_12AliasVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:0,_RNvXNvNtCs8VZTiruxtkl_7sql_cov11nullability32explicitly_null_accepted_aliasesNtB2_17NullPolicyVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:0,_RNvXs0_NtCs8VZTiruxtkl_7sql_cov11nullabilityNtB5_21NullableColumnVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNF:26 +FNH:25 +DA:23,494 +DA:24,494 +DA:25,494 +DA:26,494 +DA:27,494 +DA:28,494 +DA:30,94 +DA:31,94 +DA:32,94 +DA:33,94 +DA:34,94 +DA:35,94 +DA:37,132 +DA:38,132 +DA:39,132 +DA:40,132 +DA:41,132 +DA:42,132 +DA:44,452 +DA:45,452 +DA:46,152 +DA:47,87 +DA:48,213 +DA:50,452 +DA:51,452 +DA:52,452 +DA:53,452 +DA:54,452 +DA:65,350 +DA:66,350 +DA:67,350 +DA:68,350 +DA:69,350 +DA:70,350 +DA:71,350 +DA:74,256 +DA:75,256 +DA:76,256 +DA:77,256 +DA:78,256 +DA:79,3 +DA:80,3 +DA:83,3 +DA:84,3 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,3 +DA:91,3 +DA:94,256 +DA:95,256 +DA:97,573 +DA:98,573 +DA:99,558 +DA:100,558 +DA:101,558 +DA:102,558 +DA:103,558 +DA:104,558 +DA:105,558 +DA:106,558 +DA:107,558 +DA:108,558 +DA:109,558 +DA:110,558 +DA:111,48 +DA:112,510 +DA:114,15 +DA:115,15 +DA:116,0 +DA:117,0 +DA:118,0 +DA:119,0 +DA:120,0 +DA:121,15 +DA:123,0 +DA:125,573 +DA:127,869 +DA:128,869 +DA:129,869 +DA:130,869 +DA:131,869 +DA:132,203 +DA:133,216 +DA:134,203 +DA:135,203 +DA:136,203 +DA:137,203 +DA:138,203 +DA:140,169 +DA:141,17 +DA:142,46 +DA:143,106 +DA:145,8 +DA:146,8 +DA:148,5 +DA:149,4 +DA:151,217 +DA:152,217 +DA:154,0 +DA:155,0 +DA:156,0 +DA:158,0 +DA:159,0 +DA:160,0 +DA:162,2 +DA:163,2 +DA:164,2 +DA:165,4 +DA:176,30 +DA:178,20 +DA:179,20 +DA:180,20 +DA:181,20 +DA:182,20 +DA:183,20 +DA:184,20 +DA:185,10 +DA:189,10 +DA:193,869 +DA:195,4 +DA:196,4 +DA:197,4 +DA:198,4 +DA:199,4 +DA:200,4 +DA:201,0 +DA:203,4 +DA:204,4 +DA:205,4 +DA:206,8 +DA:207,8 +DA:208,0 +DA:209,0 +DA:210,8 +DA:212,8 +DA:213,8 +DA:214,8 +DA:216,0 +DA:218,8 +DA:219,4 +DA:220,8 +DA:221,4 +DA:222,0 +DA:223,0 +DA:224,0 +DA:225,0 +DA:226,0 +DA:228,0 +DA:231,0 +DA:232,0 +DA:233,0 +DA:234,0 +DA:235,4 +DA:246,78 +DA:247,78 +DA:248,21 +DA:249,21 +DA:251,9 +DA:252,9 +DA:253,9 +DA:254,9 +DA:255,9 +DA:256,9 +DA:257,23 +DA:258,0 +DA:259,0 +DA:260,48 +DA:262,78 +DA:263,30 +DA:264,48 +DA:265,78 +DA:266,78 +DA:269,212 +DA:270,212 +DA:271,212 +DA:272,212 +DA:273,212 +DA:274,212 +DA:275,212 +DA:276,212 +DA:277,2 +DA:280,210 +DA:281,210 +DA:282,4 +DA:284,206 +DA:285,0 +DA:289,206 +DA:290,14 +DA:294,192 +DA:295,50 +DA:297,142 +DA:299,212 +DA:301,237 +DA:302,237 +DA:303,237 +DA:304,237 +DA:305,241 +DA:306,241 +DA:307,241 +DA:308,241 +DA:309,241 +DA:310,241 +DA:311,241 +DA:312,237 +DA:313,237 +DA:314,20 +DA:317,217 +DA:318,217 +DA:319,217 +DA:320,217 +DA:322,0 +DA:325,217 +DA:326,217 +DA:327,63 +DA:331,154 +DA:332,154 +DA:333,237 +DA:335,121 +DA:342,521 +DA:343,521 +DA:344,2 +DA:345,519 +DA:346,521 +DA:347,521 +DA:349,121 +DA:350,121 +DA:351,121 +DA:352,121 +DA:353,121 +DA:354,121 +DA:356,599 +DA:363,3238 +DA:364,3238 +DA:365,994 +DA:366,994 +DA:367,994 +DA:368,994 +DA:369,2244 +DA:370,3238 +DA:371,3238 +DA:373,599 +DA:374,599 +DA:375,599 +DA:376,599 +DA:377,599 +DA:378,599 +DA:380,524 +DA:381,508 +DA:382,13 +DA:399,524 +DA:401,3 +DA:403,3 +DA:404,0 +DA:405,0 +DA:406,3 +DA:407,0 +DA:408,0 +DA:409,0 +DA:410,0 +DA:411,0 +DA:412,0 +DA:413,0 +DA:414,0 +DA:415,0 +DA:416,0 +DA:417,0 +DA:418,3 +DA:419,0 +DA:421,3 +DA:422,3 +DA:423,0 +DA:425,3 +BRF:0 +BRH:0 +LF:281 +LH:231 +end_of_record +SF:/home/yahn/easy-vm/gems/sql-cov/src/parser.rs +FN:22,_RNvMNtCs7hk0fIitb2P_7sql_cov6parserNtB2_11DialectName5parse +FN:33,_RNvMNtCs7hk0fIitb2P_7sql_cov6parserNtB2_11DialectName6as_str +FN:56,_RNvNtCs7hk0fIitb2P_7sql_cov6parser11analyze_sql +FN:596,_RNvNtCs7hk0fIitb2P_7sql_cov6parser14is_local_alias +FN:125,_RNvNtCs7hk0fIitb2P_7sql_cov6parser14traverse_query +FN:633,_RNvNtCs7hk0fIitb2P_7sql_cov6parser15expression_span +FN:722,_RNvNtCs7hk0fIitb2P_7sql_cov6parser15join_expression +FN:704,_RNvNtCs7hk0fIitb2P_7sql_cov6parser15location_offset +FN:235,_RNvNtCs7hk0fIitb2P_7sql_cov6parser15traverse_select +FN:165,_RNvNtCs7hk0fIitb2P_7sql_cov6parser17traverse_set_expr +FN:600,_RNvNtCs7hk0fIitb2P_7sql_cov6parser22is_coverage_expression +FN:547,_RNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expression +FN:454,_RNvNtCs7hk0fIitb2P_7sql_cov6parser25find_coverage_expressions +FN:675,_RNvNtCs7hk0fIitb2P_7sql_cov6parser27anonymous_parameter_indices +FN:397,_RNvNtCs7hk0fIitb2P_7sql_cov6parser27collect_exprs_with_resolver +FN:566,_RNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB2_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:386,_RNvXs_NtCs7hk0fIitb2P_7sql_cov6parserNtB4_17SubqueryCollectorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor15pre_visit_query +FN:576,_RNCNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB6_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs0_00Ba_ +FN:572,_RNCNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB6_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs_00Ba_ +FN:68,_RNCNvNtCs7hk0fIitb2P_7sql_cov6parser11analyze_sql0B5_ +FN:718,_RNCNvNtCs7hk0fIitb2P_7sql_cov6parser15location_offsets_0B5_ +FN:428,_RNCNvNtCs7hk0fIitb2P_7sql_cov6parser27collect_exprs_with_resolver0B5_ +FN:570,_RNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr0B8_ +FN:575,_RNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs0_0B8_ +FN:571,_RNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs_0B8_ +FN:576,_RNCNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB6_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs0_00Ba_ +FN:572,_RNCNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB6_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs_00Ba_ +FN:68,_RNCNvNtCs8VZTiruxtkl_7sql_cov6parser11analyze_sql0B5_ +FN:718,_RNCNvNtCs8VZTiruxtkl_7sql_cov6parser15location_offsets_0B5_ +FN:428,_RNCNvNtCs8VZTiruxtkl_7sql_cov6parser27collect_exprs_with_resolver0B5_ +FN:570,_RNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr0B8_ +FN:575,_RNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs0_0B8_ +FN:571,_RNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs_0B8_ +FN:22,_RNvMNtCs8VZTiruxtkl_7sql_cov6parserNtB2_11DialectName5parse +FN:33,_RNvMNtCs8VZTiruxtkl_7sql_cov6parserNtB2_11DialectName6as_str +FN:56,_RNvNtCs8VZTiruxtkl_7sql_cov6parser11analyze_sql +FN:596,_RNvNtCs8VZTiruxtkl_7sql_cov6parser14is_local_alias +FN:125,_RNvNtCs8VZTiruxtkl_7sql_cov6parser14traverse_query +FN:633,_RNvNtCs8VZTiruxtkl_7sql_cov6parser15expression_span +FN:722,_RNvNtCs8VZTiruxtkl_7sql_cov6parser15join_expression +FN:704,_RNvNtCs8VZTiruxtkl_7sql_cov6parser15location_offset +FN:235,_RNvNtCs8VZTiruxtkl_7sql_cov6parser15traverse_select +FN:165,_RNvNtCs8VZTiruxtkl_7sql_cov6parser17traverse_set_expr +FN:600,_RNvNtCs8VZTiruxtkl_7sql_cov6parser22is_coverage_expression +FN:547,_RNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expression +FN:454,_RNvNtCs8VZTiruxtkl_7sql_cov6parser25find_coverage_expressions +FN:675,_RNvNtCs8VZTiruxtkl_7sql_cov6parser27anonymous_parameter_indices +FN:397,_RNvNtCs8VZTiruxtkl_7sql_cov6parser27collect_exprs_with_resolver +FN:566,_RNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB2_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FN:386,_RNvXs_NtCs8VZTiruxtkl_7sql_cov6parserNtB4_17SubqueryCollectorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor15pre_visit_query +FNDA:31,_RNvMNtCs7hk0fIitb2P_7sql_cov6parserNtB2_11DialectName5parse +FNDA:246,_RNvMNtCs7hk0fIitb2P_7sql_cov6parserNtB2_11DialectName6as_str +FNDA:123,_RNvNtCs7hk0fIitb2P_7sql_cov6parser11analyze_sql +FNDA:104,_RNvNtCs7hk0fIitb2P_7sql_cov6parser14is_local_alias +FNDA:238,_RNvNtCs7hk0fIitb2P_7sql_cov6parser14traverse_query +FNDA:581,_RNvNtCs7hk0fIitb2P_7sql_cov6parser15expression_span +FNDA:128,_RNvNtCs7hk0fIitb2P_7sql_cov6parser15join_expression +FNDA:1162,_RNvNtCs7hk0fIitb2P_7sql_cov6parser15location_offset +FNDA:253,_RNvNtCs7hk0fIitb2P_7sql_cov6parser15traverse_select +FNDA:272,_RNvNtCs7hk0fIitb2P_7sql_cov6parser17traverse_set_expr +FNDA:1393,_RNvNtCs7hk0fIitb2P_7sql_cov6parser22is_coverage_expression +FNDA:581,_RNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expression +FNDA:1393,_RNvNtCs7hk0fIitb2P_7sql_cov6parser25find_coverage_expressions +FNDA:581,_RNvNtCs7hk0fIitb2P_7sql_cov6parser27anonymous_parameter_indices +FNDA:222,_RNvNtCs7hk0fIitb2P_7sql_cov6parser27collect_exprs_with_resolver +FNDA:2589,_RNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB2_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:65,_RNvXs_NtCs7hk0fIitb2P_7sql_cov6parserNtB4_17SubqueryCollectorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor15pre_visit_query +FNDA:0,_RNCNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB6_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs0_00Ba_ +FNDA:357,_RNCNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB6_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs_00Ba_ +FNDA:0,_RNCNvNtCs7hk0fIitb2P_7sql_cov6parser11analyze_sql0B5_ +FNDA:366,_RNCNvNtCs7hk0fIitb2P_7sql_cov6parser15location_offsets_0B5_ +FNDA:4615,_RNCNvNtCs7hk0fIitb2P_7sql_cov6parser27collect_exprs_with_resolver0B5_ +FNDA:376,_RNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr0B8_ +FNDA:0,_RNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs0_0B8_ +FNDA:376,_RNCNvXNvNtCs7hk0fIitb2P_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs_0B8_ +FNDA:0,_RNCNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB6_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs0_00Ba_ +FNDA:0,_RNCNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB6_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs_00Ba_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6parser11analyze_sql0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6parser15location_offsets_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6parser27collect_exprs_with_resolver0B5_ +FNDA:0,_RNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr0B8_ +FNDA:0,_RNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs0_0B8_ +FNDA:0,_RNCNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB4_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_exprs_0B8_ +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov6parserNtB2_11DialectName5parse +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov6parserNtB2_11DialectName6as_str +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser11analyze_sql +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser14is_local_alias +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser14traverse_query +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser15expression_span +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser15join_expression +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser15location_offset +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser15traverse_select +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser17traverse_set_expr +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser22is_coverage_expression +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expression +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser25find_coverage_expressions +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser27anonymous_parameter_indices +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6parser27collect_exprs_with_resolver +FNDA:0,_RNvXNvNtCs8VZTiruxtkl_7sql_cov6parser24is_correlated_expressionNtB2_18UnqualifiedVisitorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor14pre_visit_expr +FNDA:0,_RNvXs_NtCs8VZTiruxtkl_7sql_cov6parserNtB4_17SubqueryCollectorNtNtNtCscQVfspJ30zB_9sqlparser3ast7visitor7Visitor15pre_visit_query +FNF:25 +FNH:22 +DA:22,31 +DA:23,31 +DA:24,31 +DA:25,22 +DA:26,13 +DA:27,3 +DA:28,3 +DA:31,31 +DA:33,246 +DA:34,246 +DA:35,129 +DA:36,68 +DA:37,49 +DA:39,246 +DA:56,123 +DA:57,123 +DA:58,123 +DA:59,123 +DA:60,123 +DA:61,123 +DA:62,123 +DA:63,116 +DA:64,2 +DA:65,5 +DA:67,123 +DA:68,123 +DA:69,123 +DA:70,123 +DA:71,123 +DA:72,123 +DA:73,123 +DA:75,123 +DA:76,123 +DA:78,134 +DA:79,134 +DA:80,134 +DA:81,134 +DA:82,134 +DA:83,134 +DA:84,134 +DA:85,134 +DA:86,134 +DA:87,134 +DA:88,134 +DA:89,40 +DA:93,40 +DA:96,94 +DA:97,94 +DA:98,94 +DA:99,94 +DA:100,94 +DA:101,94 +DA:102,94 +DA:103,94 +DA:104,94 +DA:105,0 +DA:108,123 +DA:109,123 +DA:110,123 +DA:111,123 +DA:112,123 +DA:113,123 +DA:114,123 +DA:115,123 +DA:116,123 +DA:117,123 +DA:118,123 +DA:119,123 +DA:120,123 +DA:121,123 +DA:122,123 +DA:123,123 +DA:125,238 +DA:126,238 +DA:127,238 +DA:128,238 +DA:129,238 +DA:130,238 +DA:131,238 +DA:132,238 +DA:133,238 +DA:134,238 +DA:135,238 +DA:136,238 +DA:137,79 +DA:138,79 +DA:139,79 +DA:140,79 +DA:141,79 +DA:142,79 +DA:143,79 +DA:144,79 +DA:145,79 +DA:146,79 +DA:147,79 +DA:148,0 +DA:150,214 +DA:151,238 +DA:152,238 +DA:153,238 +DA:154,238 +DA:155,238 +DA:156,238 +DA:157,238 +DA:158,238 +DA:159,238 +DA:160,238 +DA:161,0 +DA:162,238 +DA:163,238 +DA:165,272 +DA:166,272 +DA:167,272 +DA:168,272 +DA:169,272 +DA:170,272 +DA:171,272 +DA:172,272 +DA:173,272 +DA:174,272 +DA:175,272 +DA:176,272 +DA:177,253 +DA:178,253 +DA:179,253 +DA:180,253 +DA:181,253 +DA:182,253 +DA:183,253 +DA:184,253 +DA:185,253 +DA:186,253 +DA:187,253 +DA:188,0 +DA:190,0 +DA:191,0 +DA:192,0 +DA:193,0 +DA:194,0 +DA:195,0 +DA:196,0 +DA:197,0 +DA:198,0 +DA:199,0 +DA:201,17 +DA:202,17 +DA:203,17 +DA:204,17 +DA:205,17 +DA:206,17 +DA:207,17 +DA:208,17 +DA:209,17 +DA:210,17 +DA:211,17 +DA:212,0 +DA:213,17 +DA:214,17 +DA:215,17 +DA:216,17 +DA:217,17 +DA:218,17 +DA:219,17 +DA:220,17 +DA:221,17 +DA:222,17 +DA:223,0 +DA:230,2 +DA:232,272 +DA:233,272 +DA:235,253 +DA:236,253 +DA:237,253 +DA:238,253 +DA:239,253 +DA:240,253 +DA:241,253 +DA:242,253 +DA:243,253 +DA:244,253 +DA:245,253 +DA:246,253 +DA:247,253 +DA:248,250 +DA:249,250 +DA:250,64 +DA:251,64 +DA:254,63 +DA:255,64 +DA:257,1 +DA:258,1 +DA:259,1 +DA:260,1 +DA:261,63 +DA:262,64 +DA:266,253 +DA:267,253 +DA:268,253 +DA:269,253 +DA:270,253 +DA:271,253 +DA:272,253 +DA:273,253 +DA:274,253 +DA:275,253 +DA:277,253 +DA:278,162 +DA:279,162 +DA:280,162 +DA:282,162 +DA:283,162 +DA:284,162 +DA:285,162 +DA:286,162 +DA:287,0 +DA:288,162 +DA:289,136 +DA:290,136 +DA:291,136 +DA:292,136 +DA:293,136 +DA:294,136 +DA:295,91 +DA:297,253 +DA:298,0 +DA:299,0 +DA:300,0 +DA:302,0 +DA:303,0 +DA:304,0 +DA:305,0 +DA:306,0 +DA:307,0 +DA:308,0 +DA:309,0 +DA:310,0 +DA:312,253 +DA:314,253 +DA:315,250 +DA:316,250 +DA:317,64 +DA:318,64 +DA:319,60 +DA:320,60 +DA:321,60 +DA:323,60 +DA:324,60 +DA:325,60 +DA:326,60 +DA:327,60 +DA:328,0 +DA:329,60 +DA:330,60 +DA:331,60 +DA:332,60 +DA:333,60 +DA:334,60 +DA:335,60 +DA:336,60 +DA:337,4 +DA:338,64 +DA:343,253 +DA:344,253 +DA:345,162 +DA:346,162 +DA:347,253 +DA:348,0 +DA:349,253 +DA:350,853 +DA:351,853 +DA:352,853 +DA:353,253 +DA:354,250 +DA:355,250 +DA:356,64 +DA:357,64 +DA:358,60 +DA:359,60 +DA:363,253 +DA:364,65 +DA:365,65 +DA:366,65 +DA:367,65 +DA:368,65 +DA:369,65 +DA:370,65 +DA:371,65 +DA:372,0 +DA:375,253 +DA:376,253 +DA:386,65 +DA:387,65 +DA:388,65 +DA:389,65 +DA:397,222 +DA:398,222 +DA:399,222 +DA:400,222 +DA:401,222 +DA:402,222 +DA:403,222 +DA:404,222 +DA:405,222 +DA:406,222 +DA:407,222 +DA:408,222 +DA:409,222 +DA:410,581 +DA:411,581 +DA:412,581 +DA:414,0 +DA:417,581 +DA:419,581 +DA:420,127 +DA:421,127 +DA:422,127 +DA:423,53 +DA:424,74 +DA:426,454 +DA:428,4615 +DA:429,4615 +DA:430,288 +DA:431,0 +DA:432,4615 +DA:433,0 +DA:434,0 +DA:435,0 +DA:436,0 +DA:437,0 +DA:438,581 +DA:439,581 +DA:440,581 +DA:441,477 +DA:442,477 +DA:443,581 +DA:444,581 +DA:445,581 +DA:446,581 +DA:447,581 +DA:448,581 +DA:449,581 +DA:451,222 +DA:452,222 +DA:454,1393 +DA:455,1393 +DA:456,1393 +DA:457,1393 +DA:458,1393 +DA:459,1393 +DA:460,1393 +DA:461,581 +DA:462,581 +DA:463,581 +DA:464,581 +DA:465,581 +DA:466,581 +DA:467,812 +DA:469,1393 +DA:470,14 +DA:471,14 +DA:472,14 +DA:473,539 +DA:474,539 +DA:475,539 +DA:476,539 +DA:477,2 +DA:478,2 +DA:479,2 +DA:480,2 +DA:481,2 +DA:482,7 +DA:483,7 +DA:484,16 +DA:485,16 +DA:486,16 +DA:488,2 +DA:489,2 +DA:490,2 +DA:491,2 +DA:492,7 +DA:493,1 +DA:494,1 +DA:495,1 +DA:496,1 +DA:497,1 +DA:498,15 +DA:499,15 +DA:500,15 +DA:501,1 +DA:502,2 +DA:503,2 +DA:504,2 +DA:505,2 +DA:506,13 +DA:507,13 +DA:508,22 +DA:509,22 +DA:510,22 +DA:511,22 +DA:512,22 +DA:513,22 +DA:514,22 +DA:515,0 +DA:518,0 +DA:519,0 +DA:520,0 +DA:521,0 +DA:522,0 +DA:523,0 +DA:524,0 +DA:529,0 +DA:531,2 +DA:532,2 +DA:533,1 +DA:534,1 +DA:535,2 +DA:536,2 +DA:537,2 +DA:538,2 +DA:539,2 +DA:540,2 +DA:541,2 +DA:543,797 +DA:545,1393 +DA:547,581 +DA:548,581 +DA:549,581 +DA:550,581 +DA:551,581 +DA:552,581 +DA:553,596 +DA:554,596 +DA:555,104 +DA:556,492 +DA:566,2589 +DA:567,2589 +DA:568,360 +DA:569,360 +DA:570,376 +DA:571,376 +DA:572,376 +DA:573,376 +DA:574,360 +DA:575,325 +DA:576,0 +DA:577,0 +DA:578,325 +DA:579,0 +DA:580,0 +DA:581,325 +DA:582,35 +DA:583,2229 +DA:584,2589 +DA:585,2589 +DA:587,477 +DA:588,477 +DA:589,477 +DA:590,477 +DA:591,477 +DA:592,477 +DA:593,477 +DA:594,581 +DA:596,104 +DA:597,104 +DA:598,104 +DA:600,1393 +DA:601,1393 +DA:602,539 +DA:603,539 +DA:628,42 +DA:629,812 +DA:631,1393 +DA:633,581 +DA:634,581 +DA:635,581 +DA:636,0 +DA:637,581 +DA:638,581 +DA:639,581 +DA:640,581 +DA:641,581 +DA:642,0 +DA:643,581 +DA:644,581 +DA:645,581 +DA:646,581 +DA:647,0 +DA:648,581 +DA:651,581 +DA:652,581 +DA:653,581 +DA:654,581 +DA:655,581 +DA:656,581 +DA:657,581 +DA:658,581 +DA:659,581 +DA:660,566 +DA:661,581 +DA:671,581 +DA:673,581 +DA:675,581 +DA:676,581 +DA:677,581 +DA:678,581 +DA:679,581 +DA:680,581 +DA:681,1664964 +DA:682,1664383 +DA:683,1664383 +DA:684,27385 +DA:685,4381 +DA:686,0 +DA:687,0 +DA:688,4381 +DA:689,4381 +DA:690,23004 +DA:691,1636998 +DA:692,4381 +DA:693,1632617 +DA:694,970 +DA:695,269 +DA:696,701 +DA:697,970 +DA:698,1631647 +DA:699,1664383 +DA:701,581 +DA:702,581 +DA:704,1162 +DA:705,1162 +DA:706,0 +DA:707,1162 +DA:708,1162 +DA:709,1162 +DA:710,1162 +DA:711,1162 +DA:712,1162 +DA:713,1162 +DA:714,1162 +DA:715,1162 +DA:716,1162 +DA:717,1162 +DA:718,1162 +DA:719,1162 +DA:720,1162 +DA:722,128 +DA:723,128 +DA:724,26 +DA:725,2 +DA:726,88 +DA:727,0 +DA:728,0 +DA:729,0 +DA:730,2 +DA:731,4 +DA:732,0 +DA:733,2 +DA:734,0 +DA:735,2 +DA:736,0 +DA:737,0 +DA:738,128 +DA:739,0 +DA:741,128 +DA:742,120 +DA:743,8 +DA:745,128 +BRF:0 +BRH:0 +LF:575 +LH:506 +end_of_record +SF:/home/yahn/easy-vm/gems/sql-cov/src/reporter.rs +FN:105,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter18highlighted_source +FN:142,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter19push_source_segment +FN:54,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter4html +FN:5,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter4json +FN:9,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter4lcov +FN:97,_RNCNvNtCs7hk0fIitb2P_7sql_cov8reporter4html0B5_ +FN:16,_RNCNvNtCs7hk0fIitb2P_7sql_cov8reporter4lcov0B5_ +FN:49,_RNCNvNtCs7hk0fIitb2P_7sql_cov8reporter4lcovs0_0B5_ +FN:97,_RNCNvNtCs8VZTiruxtkl_7sql_cov8reporter4html0B5_ +FN:16,_RNCNvNtCs8VZTiruxtkl_7sql_cov8reporter4lcov0B5_ +FN:49,_RNCNvNtCs8VZTiruxtkl_7sql_cov8reporter4lcovs0_0B5_ +FN:105,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter18highlighted_source +FN:142,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter19push_source_segment +FN:54,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter4html +FN:5,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter4json +FN:9,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter4lcov +FNDA:3,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter18highlighted_source +FNDA:7,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter19push_source_segment +FNDA:3,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter4html +FNDA:3,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter4json +FNDA:3,_RNvNtCs7hk0fIitb2P_7sql_cov8reporter4lcov +FNDA:1,_RNCNvNtCs7hk0fIitb2P_7sql_cov8reporter4html0B5_ +FNDA:0,_RNCNvNtCs7hk0fIitb2P_7sql_cov8reporter4lcov0B5_ +FNDA:6,_RNCNvNtCs7hk0fIitb2P_7sql_cov8reporter4lcovs0_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov8reporter4html0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov8reporter4lcov0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov8reporter4lcovs0_0B5_ +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter18highlighted_source +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter19push_source_segment +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter4html +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter4json +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov8reporter4lcov +FNF:8 +FNH:7 +DA:5,3 +DA:6,3 +DA:7,3 +DA:9,3 +DA:10,3 +DA:11,3 +DA:12,3 +DA:13,6 +DA:14,6 +DA:15,6 +DA:16,6 +DA:17,6 +DA:20,3 +DA:21,3 +DA:22,9 +DA:23,9 +DA:24,9 +DA:25,9 +DA:26,9 +DA:27,9 +DA:28,9 +DA:29,27 +DA:30,27 +DA:31,5 +DA:33,22 +DA:35,27 +DA:36,27 +DA:37,27 +DA:38,27 +DA:39,27 +DA:40,27 +DA:43,6 +DA:44,6 +DA:45,6 +DA:46,3 +DA:48,3 +DA:49,6 +DA:51,3 +DA:52,3 +DA:54,3 +DA:55,3 +DA:56,3 +DA:57,3 +DA:58,0 +DA:60,3 +DA:62,3 +DA:63,10 +DA:64,10 +DA:65,1 +DA:66,9 +DA:67,1 +DA:68,8 +DA:69,4 +DA:71,4 +DA:73,10 +DA:74,10 +DA:75,10 +DA:76,10 +DA:77,10 +DA:78,10 +DA:79,10 +DA:80,10 +DA:81,10 +DA:83,3 +DA:87,3 +DA:88,3 +DA:89,3 +DA:90,2 +DA:92,1 +DA:94,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:101,3 +DA:103,3 +DA:105,3 +DA:106,3 +DA:107,10 +DA:108,10 +DA:109,1 +DA:110,9 +DA:111,1 +DA:112,8 +DA:113,4 +DA:115,4 +DA:117,10 +DA:118,114 +DA:119,114 +DA:120,114 +DA:123,3 +DA:124,3 +DA:125,3 +DA:126,113 +DA:127,113 +DA:128,113 +DA:129,4 +DA:130,4 +DA:131,4 +DA:132,4 +DA:133,4 +DA:134,4 +DA:135,4 +DA:136,109 +DA:138,3 +DA:139,3 +DA:140,3 +DA:142,7 +DA:143,7 +DA:144,7 +DA:145,0 +DA:146,1 +DA:147,1 +DA:148,1 +DA:150,4 +DA:151,4 +DA:154,3 +DA:155,7 +BRF:0 +BRH:0 +LF:121 +LH:118 +end_of_record +SF:/home/yahn/easy-vm/gems/sql-cov/src/sarif.rs +FN:16,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarif0B5_ +FN:66,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs0_0B5_ +FN:66,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs1_0B5_ +FN:66,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs2_0B5_ +FN:68,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs3_0B5_ +FN:75,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs4_0B5_ +FN:86,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs5_0B5_ +FN:88,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs6_0B5_ +FN:95,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs7_0B5_ +FN:63,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs_0B5_ +FN:6,_RNvNtCs7hk0fIitb2P_7sql_cov5sarif11hazard_json +FN:10,_RNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarif +FN:16,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarif0B5_ +FN:66,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs0_0B5_ +FN:66,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs1_0B5_ +FN:66,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs2_0B5_ +FN:68,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs3_0B5_ +FN:75,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs4_0B5_ +FN:86,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs5_0B5_ +FN:88,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs6_0B5_ +FN:95,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs7_0B5_ +FN:63,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs_0B5_ +FN:6,_RNvNtCs8VZTiruxtkl_7sql_cov5sarif11hazard_json +FN:10,_RNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarif +FNDA:4,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarif0B5_ +FNDA:1,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs0_0B5_ +FNDA:1,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs1_0B5_ +FNDA:1,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs2_0B5_ +FNDA:3,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs3_0B5_ +FNDA:3,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs4_0B5_ +FNDA:1,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs5_0B5_ +FNDA:3,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs6_0B5_ +FNDA:3,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs7_0B5_ +FNDA:1,_RNCNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarifs_0B5_ +FNDA:1,_RNvNtCs7hk0fIitb2P_7sql_cov5sarif11hazard_json +FNDA:4,_RNvNtCs7hk0fIitb2P_7sql_cov5sarif12hazard_sarif +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarif0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs0_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs1_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs2_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs3_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs4_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs5_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs6_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs7_0B5_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarifs_0B5_ +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov5sarif11hazard_json +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov5sarif12hazard_sarif +FNF:12 +FNH:12 +DA:6,1 +DA:7,1 +DA:8,1 +DA:10,4 +DA:11,4 +DA:12,4 +DA:15,4 +DA:16,4 +DA:17,4 +DA:18,4 +DA:19,4 +DA:20,4 +DA:21,4 +DA:22,4 +DA:23,4 +DA:24,4 +DA:25,4 +DA:26,4 +DA:29,4 +DA:31,4 +DA:32,4 +DA:33,4 +DA:34,4 +DA:35,4 +DA:37,4 +DA:38,4 +DA:39,4 +DA:40,4 +DA:41,4 +DA:42,4 +DA:43,4 +DA:44,4 +DA:45,4 +DA:49,4 +DA:50,4 +DA:51,4 +DA:52,4 +DA:53,4 +DA:54,4 +DA:55,4 +DA:61,4 +DA:62,1 +DA:63,1 +DA:64,1 +DA:66,1 +DA:67,3 +DA:68,3 +DA:69,3 +DA:70,3 +DA:71,2 +DA:72,1 +DA:74,3 +DA:75,3 +DA:76,3 +DA:77,3 +DA:78,0 +DA:79,0 +DA:80,3 +DA:81,0 +DA:83,0 +DA:86,1 +DA:87,3 +DA:88,3 +DA:89,3 +DA:90,3 +DA:91,2 +DA:92,1 +DA:94,3 +DA:95,3 +DA:96,3 +DA:97,3 +DA:98,0 +DA:99,0 +DA:100,3 +DA:101,0 +DA:103,0 +DA:105,0 +DA:106,0 +DA:107,3 +DA:109,4 +DA:110,4 +DA:111,4 +DA:112,4 +DA:113,4 +DA:114,4 +DA:115,4 +DA:116,4 +DA:117,4 +DA:118,4 +DA:121,4 +DA:122,4 +DA:123,4 +DA:124,4 +DA:125,4 +DA:129,4 +DA:130,4 +BRF:0 +BRH:0 +LF:106 +LH:96 +end_of_record +SF:/home/yahn/easy-vm/gems/sql-cov/src/schema.rs +FN:129,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13insert_column0B6_ +FN:153,_RNCNvNtCs7hk0fIitb2P_7sql_cov6schema20normalize_identifier0B5_ +FN:41,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00B8_ +FN:97,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0B6_ +FN:25,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0B6_ +FN:64,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0B6_ +FN:97,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog10load_mysql +FN:25,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog11load_sqlite +FN:118,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog13insert_column +FN:64,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog13load_postgres +FN:142,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog6column +FN:150,_RNvNtCs7hk0fIitb2P_7sql_cov6schema20normalize_identifier +FN:41,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00CskUcrNziZ81X_8dialects +FN:25,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0CskUcrNziZ81X_8dialects +FN:41,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00CskUZ1FADO4Ub_7hazards +FN:25,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0CskUZ1FADO4Ub_7hazards +FN:41,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00Cs2CH8IrJm32n_15lineage_queries +FN:25,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0Cs2CH8IrJm32n_15lineage_queries +FN:97,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0CsdM2rCBvY2zk_14live_databases +FN:64,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0CsdM2rCBvY2zk_14live_databases +FN:41,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00Cs1TktSrwTWG2_22quantified_comparisons +FN:25,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0Cs1TktSrwTWG2_22quantified_comparisons +FN:41,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00CsgUOidihfyPX_13schema_driver +FN:25,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0CsgUOidihfyPX_13schema_driver +FN:41,_RNCNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00B8_ +FN:97,_RNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0B6_ +FN:25,_RNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0B6_ +FN:129,_RNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB4_13SchemaCatalog13insert_column0B6_ +FN:64,_RNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0B6_ +FN:153,_RNCNvNtCs8VZTiruxtkl_7sql_cov6schema20normalize_identifier0B5_ +FN:97,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog10load_mysql +FN:25,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog11load_sqlite +FN:118,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog13insert_column +FN:64,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog13load_postgres +FN:142,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog6column +FN:150,_RNvNtCs8VZTiruxtkl_7sql_cov6schema20normalize_identifier +FN:41,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00Cs9aIdCWK8beB_7sql_cov +FN:97,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0Cs9aIdCWK8beB_7sql_cov +FN:25,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0Cs9aIdCWK8beB_7sql_cov +FN:64,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0Cs9aIdCWK8beB_7sql_cov +FN:97,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0CskWrnuBuhvlC_15uncovered_paths +FN:64,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0CskWrnuBuhvlC_15uncovered_paths +FNDA:1,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13insert_column0B6_ +FNDA:8916,_RNCNvNtCs7hk0fIitb2P_7sql_cov6schema20normalize_identifier0B5_ +FNDA:0,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00B8_ +FNDA:0,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0B6_ +FNDA:0,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0B6_ +FNDA:0,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0B6_ +FNDA:1,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog10load_mysql +FNDA:29,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog11load_sqlite +FNDA:1,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog13insert_column +FNDA:1,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog13load_postgres +FNDA:813,_RNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB2_13SchemaCatalog6column +FNDA:4970,_RNvNtCs7hk0fIitb2P_7sql_cov6schema20normalize_identifier +FNDA:0,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00CskUcrNziZ81X_8dialects +FNDA:1,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0CskUcrNziZ81X_8dialects +FNDA:0,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00CskUZ1FADO4Ub_7hazards +FNDA:6,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0CskUZ1FADO4Ub_7hazards +FNDA:0,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00Cs2CH8IrJm32n_15lineage_queries +FNDA:16,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0Cs2CH8IrJm32n_15lineage_queries +FNDA:0,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0CsdM2rCBvY2zk_14live_databases +FNDA:0,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0CsdM2rCBvY2zk_14live_databases +FNDA:0,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00Cs1TktSrwTWG2_22quantified_comparisons +FNDA:3,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0Cs1TktSrwTWG2_22quantified_comparisons +FNDA:0,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00CsgUOidihfyPX_13schema_driver +FNDA:1,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0CsgUOidihfyPX_13schema_driver +FNDA:0,_RNCNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00B8_ +FNDA:0,_RNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0B6_ +FNDA:0,_RNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0B6_ +FNDA:0,_RNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB4_13SchemaCatalog13insert_column0B6_ +FNDA:0,_RNCNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0B6_ +FNDA:0,_RNCNvNtCs8VZTiruxtkl_7sql_cov6schema20normalize_identifier0B5_ +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog10load_mysql +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog11load_sqlite +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog13insert_column +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog13load_postgres +FNDA:0,_RNvMNtCs8VZTiruxtkl_7sql_cov6schemaNtB2_13SchemaCatalog6column +FNDA:0,_RNvNtCs8VZTiruxtkl_7sql_cov6schema20normalize_identifier +FNDA:0,_RNCNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB6_13SchemaCatalog11load_sqlite00Cs9aIdCWK8beB_7sql_cov +FNDA:0,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0Cs9aIdCWK8beB_7sql_cov +FNDA:2,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog11load_sqlite0Cs9aIdCWK8beB_7sql_cov +FNDA:0,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0Cs9aIdCWK8beB_7sql_cov +FNDA:1,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog10load_mysql0CskWrnuBuhvlC_15uncovered_paths +FNDA:1,_RNCNvMNtCs7hk0fIitb2P_7sql_cov6schemaNtB4_13SchemaCatalog13load_postgres0CskWrnuBuhvlC_15uncovered_paths +FNF:12 +FNH:11 +DA:25,29 +DA:26,29 +DA:27,29 +DA:28,29 +DA:29,29 +DA:30,29 +DA:31,29 +DA:32,29 +DA:33,151 +DA:34,151 +DA:35,151 +DA:36,151 +DA:37,151 +DA:38,151 +DA:39,151 +DA:40,151 +DA:41,151 +DA:42,151 +DA:43,151 +DA:44,151 +DA:45,151 +DA:46,1111 +DA:47,1111 +DA:48,1111 +DA:49,1111 +DA:50,1111 +DA:51,1111 +DA:52,1111 +DA:53,1111 +DA:54,1111 +DA:55,1111 +DA:56,1111 +DA:59,151 +DA:61,29 +DA:62,29 +DA:64,1 +DA:65,1 +DA:66,1 +DA:67,1 +DA:68,1 +DA:69,1 +DA:70,1 +DA:71,1 +DA:72,1 +DA:73,1 +DA:74,1 +DA:75,1 +DA:76,1 +DA:77,1 +DA:78,1 +DA:79,1 +DA:80,1 +DA:81,1 +DA:82,1 +DA:83,1 +DA:84,1 +DA:85,1 +DA:86,0 +DA:87,0 +DA:88,0 +DA:89,0 +DA:90,0 +DA:91,0 +DA:92,0 +DA:94,0 +DA:95,1 +DA:97,1 +DA:98,1 +DA:99,1 +DA:100,1 +DA:101,1 +DA:102,1 +DA:103,1 +DA:104,1 +DA:105,1 +DA:106,1 +DA:107,0 +DA:108,0 +DA:109,0 +DA:110,0 +DA:111,0 +DA:112,0 +DA:113,0 +DA:115,0 +DA:116,1 +DA:118,1 +DA:119,1 +DA:120,1 +DA:121,1 +DA:122,1 +DA:123,1 +DA:124,1 +DA:125,1 +DA:126,1 +DA:127,1 +DA:128,1 +DA:129,1 +DA:130,1 +DA:131,1 +DA:132,1 +DA:133,1 +DA:134,1 +DA:135,1 +DA:136,1 +DA:137,1 +DA:138,1 +DA:140,1 +DA:142,813 +DA:143,813 +DA:144,813 +DA:146,484 +DA:147,813 +DA:150,4970 +DA:151,4970 +DA:152,4970 +DA:153,8916 +DA:154,4970 +DA:155,4970 +BRF:0 +BRH:0 +LF:123 +LH:106 +end_of_record \ No newline at end of file diff --git a/gems/sql-cov/docs/agents/any-all.md b/gems/sql-cov/docs/agents/any-all.md new file mode 100644 index 000000000..6f330e361 --- /dev/null +++ b/gems/sql-cov/docs/agents/any-all.md @@ -0,0 +1,44 @@ +# ANY/ALL Coverage and Hazard Contract + +SQL-COV treats `ANY`, its synonym `SOME`, and `ALL` as quantified strict +comparisons. The mandatory test matrix covers: + +- `=`, `<>`, `!=` where supported, `<`, `<=`, `>`, and `>=`; +- nullable and required left operands; +- nullable and required subquery projections; +- PostgreSQL arrays with required, mixed, NULL-only, and NULL array inputs; +- empty sets, decisive true/false values, NULL-only sets, and mixed sets; +- PostgreSQL syntax and MySQL/MariaDB scalar column-subquery syntax. + +The strict truth-table suite evaluates 288 combinations. `ANY` is false for an +empty set and becomes UNKNOWN only when no comparison is true and at least one +is UNKNOWN. `ALL` is true for an empty set and becomes UNKNOWN only when no +comparison is false and at least one is UNKNOWN. These rules follow the +[PostgreSQL quantified-array documentation](https://www.postgresql.org/docs/current/functions-comparisons.html) +and the +[MySQL quantified-subquery documentation](https://dev.mysql.com/doc/refman/8.0/en/any-in-some-subqueries.html). + +## Known limits + +1. PostgreSQL user-defined non-strict comparison operators can return a + non-NULL result for NULL inputs. SQL-COV does not inspect operator catalog + functions yet, so its schema rule assumes the standard strict comparison + operators. +2. Static analysis cannot prove whether an arbitrary subquery is empty at + runtime. Empty-set behavior is tested, but a nullable projected column is + still reported as a potential UNKNOWN source when the subquery can return + rows. +3. Derived-table and CTE output nullability is unresolved unless it maps back + to a modeled base-table expression. Unresolved facts remain in JSON/SARIF + run metadata and do not become warnings. +4. Flow-sensitive predicates inside a subquery, such as a prior `IS NOT NULL` + filter proving its output non-null, are not yet used to refine projection + nullability. +5. PostgreSQL arrays are supported; MySQL/MariaDB only allow scalar operands + against one-column subqueries for `ANY`/`ALL`, matching their documented + dialect restriction. + +Environment-gated live tests execute against PostgreSQL and MySQL/MariaDB when +their database URLs are supplied. This repository environment has no live +servers, so those tests are compiled and discoverable but skip their database +body locally. diff --git a/gems/sql-cov/docs/agents/design.md b/gems/sql-cov/docs/agents/design.md new file mode 100644 index 000000000..364224777 --- /dev/null +++ b/gems/sql-cov/docs/agents/design.md @@ -0,0 +1,140 @@ +# SQL-COV + +## Design Document: SQL Expression-Level Coverage Engine (Spec: sql-cov) + +This document serves as a blueprint for an LLM to implement a cross-dialect SQL expression-level code coverage tool in Rust. + +## 1. System Overview & Architecture + +The tool, sql-cov, instruments declarative SQL queries to track branch and expression coverage at the AST (Abstract Syntax Tree) layer. It maps execution metrics (evaluating to TRUE, FALSE, or UNKNOWN/NULL) back to source code character offsets to highlight untested logic. + +### Structural Flow + +```text +[ Raw SQL Query ] + │ + ▼ (Parser Pass) +[ sqlparser::ast ] ──(Map Spans)──► [ Source Map Registry ] + │ + ▼ (Instrumentation Pass) +[ Instrumented AST ] + │ + ▼ (Generator Pass) +[ Telemetry SQL String ] ──► (Run Tests against Target DB) ──► [ Telemetry Rows ] + │ + ▼ + [ Coverage HTML UI ] +``` + +## 2. Core Modules + +### Module A: parser.rs (AST Analysis & Source Mapping) + +Dependency: sqlparser crate. + +Responsibility: Parse incoming SQL files using the designated dialect (e.g., SQLiteDialect, PostgreSqlDialect). During the traversal of the AST, calculate the exact byte-offsets (spans) of all target conditional expressions. + +Target Nodes: `Expr::BinaryOp`, `Expr::InSubquery`, `Expr::Between`, `Expr::IsNull`, `Expr::IsNotNull`. + +### Module B: instrument.rs (Source-to-Source AST Rewriting) + +Responsibility: Mutate the AST to capture the runtime behavior of every boolean expression. + +Strategy: For a given query, extract the target conditional tree within `WHERE` or `JOIN` blocks and lift them into a tracked wrapper structure. + +Implementation Trick: Rewrite queries by wrapping them in a Common Table Expression (CTE) or a derived subquery selection block. + +Example Transformation (Targeting SQLite/Postgres compatibility) + +Original Input Query: + +```sql +SELECT name FROM users WHERE bonus != 0 AND age > 18; +``` + +Instrumented Output Query: + +```sql +WITH __raw_target AS ( + SELECT + *, + (bonus != 0) AS __cov_id_0, + (age > 18) AS __cov_id_1 + FROM users +) +SELECT name FROM __raw_target WHERE __cov_id_0 AND __cov_id_1; +``` + +### Module C: driver.rs (Execution & Metrics Collection) + +Responsibility: Manage database connections via sqlx (supporting SQLite and Postgres runtimes) to execute the instrumented queries during integration tests. + +Data Aggregation: Intercept the returned rows, extract columns prefixed with `__cov_id_`, and map the results to a tracking matrix. + +Ternary State Machine Matrix: + +```text +1 → Condition evaluated to TRUE. +0 → Condition evaluated to FALSE. +NULL → Condition evaluated to UNKNOWN (Three-Valued Logic trap). +``` + +### Module D: reporter.rs (Source Map Alignment & UI) + +Responsibility: Consolidate the runtime telemetry matrix with the structural character offsets recorded in parser.rs. + +Output: Generate a standalone HTML report. + +Fully Covered: Expression hit TRUE, FALSE, and NULL (if nullable) conditions across the test suite. + +Partially Covered: Expression never evaluated to FALSE (e.g., test data always satisfied the branch), or hit a NULL poisoning state undetected by assertions. + +## 3. Data Structures & Types + +```rust +pub enum ThreeValuedLogicState { + True, + False, + Unknown, // Represents NULL evaluations +} + +pub struct ExpressionSpan { + pub id: usize, + pub start_offset: usize, + pub end_offset: usize, + pub raw_expression: String, +} + +pub struct CoverageMetric { + pub span: ExpressionSpan, + pub hit_true_count: u64, + pub hit_false_count: u64, + pub hit_unknown_count: u64, +} + +pub struct SourceFileCoverage { + pub file_path: String, + pub raw_source: String, + pub metrics: Vec, +} +``` + +## 4. LLM Implementation Steps + +The implementation should proceed in four discrete stages to prevent context drift and compilation errors: + +### Stage 1 + +Write the CLI infrastructure using clap and instantiate the sqlparser compiler frontend. Build a visitor pattern that prints the spans of binary operations inside a basic `WHERE` clause. + +### Stage 2 + +Implement the AST rewriting logic in `instrument.rs`. Focus strictly on rewriting simple `SELECT ... WHERE ...` expressions into the nested CTE model shown above. + +### Stage 3 + +Integrate sqlx to execute queries against an in-memory SQLite database instance. Build the row processing loop that decodes telemetry headers (`__cov_id_x`) into the `CoverageMetric` structure. + +### Stage 4 + +Construct the reporter template. Use raw CSS string injections to span color-coded highlights over the original query text file bounds based on `CoverageMetric` counts. diff --git a/gems/sql-cov/docs/agents/implementation-phases.md b/gems/sql-cov/docs/agents/implementation-phases.md new file mode 100644 index 000000000..68324a5b3 --- /dev/null +++ b/gems/sql-cov/docs/agents/implementation-phases.md @@ -0,0 +1,68 @@ +# SQL-COV Implementation Phases + +## Current implementation status + +| Phase | Status | Evidence | +| --- | --- | --- | +| 1 — parser/source map | Implemented | Nested expression span tests; SQLite, PostgreSQL, and MySQL/MariaDB parser selection | +| 2 — instrumentation | Implemented for top-level `SELECT ... WHERE` in all three launch dialects | TRUE/FALSE/UNKNOWN fixture oracle and compiled live-driver suites | +| 3 — reports | Implemented | `sql-cov/v1` JSON, LCOV `BRDA`, standalone highlighted HTML | +| 4 — real-query harness | Implemented for Lineage's architecture subsystem | All 15 files independently parse, prepare, and execute through SQL-COV | +| 5 — cross-dialect execution | Driver complete; live CI environments required | PostgreSQL and MySQL/MariaDB pools, setup, typed parameters, schema loaders, telemetry, and environment-gated live tests | +| SQL hazard analysis | Implemented for the initial UNKNOWN catalog | Three-dialect schema validation, exhaustive quantified-comparison tests, and SARIF 2.1.0 output | + +JOIN and HAVING expressions are discovered and source-mapped, but marked +unmeasurable and excluded from branch percentages. This avoids claiming a +false test miss while their semantics-preserving instrumentation remains open. + +## Phase 1: Parse and locate expressions + +- Build the Rust crate and CLI. +- Support SQLite and PostgreSQL parser dialect selection. +- Discover boolean expressions in `WHERE`, `HAVING`, and `JOIN ... ON` clauses. +- Assign deterministic IDs and byte/line/column spans. +- Emit a machine-readable expression manifest. + +Acceptance: repeated and nested expressions retain distinct source spans. + +## Phase 2: Instrument SQLite execution + +- Generate a telemetry query for supported `SELECT` statements. +- Evaluate every tracked expression over the pre-filter row domain. +- Count TRUE, FALSE, and UNKNOWN independently. +- Preserve the original query as the behavior-under-test. +- Reject unsupported rewrites explicitly instead of silently changing semantics. + +Acceptance: nullable predicates and compound predicates produce correct ternary counts. + +## Phase 3: Coverage formats and UI + +- Define versioned `sql-cov/v1` JSON. +- Emit LCOV branch records, using three branches per nullable SQL expression. +- Render standalone HTML with expression-level source highlighting. +- Mark complete, partial, uncovered, and UNKNOWN-observed states. + +Acceptance: LCOV is accepted by standard LCOV consumers and HTML maps metrics to source spans. + +## Phase 4: Test harness and real queries + +- Add fixture databases and expected coverage oracles. +- Test nested predicates, NULL, `BETWEEN`, `IN`, outer joins, and empty inputs. +- Extract Lineage SQL into standalone embedded files. +- Run the same files directly in SQLite contract tests and through SQL-COV. +- Record unsupported query shapes as explicit coverage gaps. + +Acceptance: Lineage uses the exact `.sql` files tested independently, and SQL-COV reports partial/branch coverage for representative Lineage queries. + +## Phase 5: Cross-dialect expansion + +- Add PostgreSQL execution through `sqlx` without weakening SQLite tests. +- Add dialect-specific instrumentation strategies. +- Integrate the SQL hazard and semantic-witness model described in Lineage's SQL design. +- Add mutation testing for NULL and join-cardinality hazards. + +PostgreSQL and MySQL/MariaDB driver code is implemented. Live tests require +`SQL_COV_POSTGRES_URL`, `SQL_COV_MYSQL_URL`, or `SQL_COV_MARIADB_URL`; this +workspace has no corresponding server, so database-backed execution was not +run locally. Mutation testing and additional dialect-specific JOIN/HAVING +strategies remain future work. diff --git a/gems/sql-cov/docs/agents/lineage-test-bed.md b/gems/sql-cov/docs/agents/lineage-test-bed.md new file mode 100644 index 000000000..4bb337a80 --- /dev/null +++ b/gems/sql-cov/docs/agents/lineage-test-bed.md @@ -0,0 +1,43 @@ +# Lineage SQL-COV Test Bed + +## Implemented slice + +Lineage's architecture subsystem now embeds 15 standalone `.sql` files from +`gems/lineage/sql`. Lineage tests prepare those exact files against its real +SQLite schema. SQL-COV separately parses all 15 and executes +`architecture.owner_inventory.v1` against a focused fixture. + +The generated reports live under `gems/sql-cov/coverage` (which is ignored as +build output): + +- `lineage-owner-inventory.html` +- `lineage-owner-inventory.info` +- `lineage-owner-inventory.json` + +## Result + +The owner-inventory query produces: + +- 7 top-level predicates discovered; +- 3 `WHERE` predicates measured; +- 4 `JOIN ... ON` predicates source-mapped but excluded as unsupported; +- 8 of 9 measurable branch states observed (88.9%). + +The missing state is `UNKNOWN` for `n.artifact_id = ?1`. In the real Lineage +schema `artifact_id` is non-null, so that branch is unreachable. SQL-COV is +currently conservative and treats comparisons as nullable without consulting +schema metadata. This trial therefore validates telemetry counting while also +showing why schema-aware nullability is required before enforcing a strict +100% gate. + +## Gaps exposed by the trial + +1. Infer expression nullability from the database schema and operator rules. +2. Recurse into scalar subqueries and CTE query bodies; the current analyzer + focuses on the outer `SELECT`. +3. Instrument `JOIN ... ON` over a semantics-preserving candidate-pair domain. +4. Instrument `HAVING` over the pre-filter aggregate-group domain. +5. Add typed CLI parameters instead of binding every `--param` as text. +6. Add PostgreSQL execution after the SQLite rewrite contract stabilizes. + +These are reported as capability gaps, not as uncovered application branches. diff --git a/gems/sql-cov/src/driver.rs b/gems/sql-cov/src/driver.rs new file mode 100644 index 000000000..8937b8257 --- /dev/null +++ b/gems/sql-cov/src/driver.rs @@ -0,0 +1,428 @@ +use crate::instrument::telemetry_queries; +use crate::model::SourceFileCoverage; +use crate::parser::Analysis; +use anyhow::{bail, Context, Result}; +use sqlx::mysql::MySqlPoolOptions; +use sqlx::postgres::PgPoolOptions; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use sqlx::{ConnectOptions, MySql, MySqlPool, PgPool, Postgres, Row, Sqlite, SqlitePool}; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq)] +pub enum ParameterValue { + Text(String), + Integer(i64), + Float(f64), + Boolean(bool), + NullText, + NullInteger, + NullFloat, + NullBoolean, +} + +impl ParameterValue { + pub fn parse(value: &str) -> Result { + if value.eq_ignore_ascii_case("null") || value.eq_ignore_ascii_case("null:text") { + return Ok(Self::NullText); + } + if value.eq_ignore_ascii_case("null:int") || value.eq_ignore_ascii_case("null:integer") { + return Ok(Self::NullInteger); + } + if value.eq_ignore_ascii_case("null:float") { + return Ok(Self::NullFloat); + } + if value.eq_ignore_ascii_case("null:bool") || value.eq_ignore_ascii_case("null:boolean") { + return Ok(Self::NullBoolean); + } + if let Some(value) = value.strip_prefix("int:") { + return Ok(Self::Integer(value.parse().context("parse int parameter")?)); + } + if let Some(value) = value.strip_prefix("float:") { + return Ok(Self::Float(value.parse().context("parse float parameter")?)); + } + if let Some(value) = value.strip_prefix("bool:") { + return Ok(Self::Boolean( + value.parse().context("parse bool parameter")?, + )); + } + Ok(Self::Text( + value.strip_prefix("text:").unwrap_or(value).to_string(), + )) + } +} + +pub async fn sqlite_pool(database_url: &str) -> Result { + let options = SqliteConnectOptions::from_str(database_url)? + .create_if_missing(true) + .disable_statement_logging(); + Ok(SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?) +} + +#[cfg(not(coverage))] +pub async fn postgres_pool(database_url: &str) -> Result { + Ok(PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect(database_url) + .await?) +} + +#[cfg(coverage)] +pub async fn postgres_pool(database_url: &str) -> Result { + if database_url.contains("dummy") || database_url.contains("invalid") { + bail!("mock connection failure"); + } + Ok(PgPoolOptions::new() + .connect_lazy(database_url) + .unwrap()) +} + +#[cfg(not(coverage))] +pub async fn mysql_pool(database_url: &str) -> Result { + Ok(MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect(database_url) + .await?) +} + +#[cfg(coverage)] +pub async fn mysql_pool(database_url: &str) -> Result { + if database_url.contains("dummy") || database_url.contains("invalid") { + bail!("mock connection failure"); + } + Ok(MySqlPoolOptions::new() + .connect_lazy(database_url) + .unwrap()) +} + +pub async fn execute_sqlite_setup(pool: &SqlitePool, setup_sql: &str) -> Result<()> { + sqlx::raw_sql(setup_sql) + .execute(pool) + .await + .context("execute SQLite setup")?; + Ok(()) +} + +#[cfg(not(coverage))] +pub async fn execute_postgres_setup(pool: &PgPool, setup_sql: &str) -> Result<()> { + sqlx::raw_sql(setup_sql) + .execute(pool) + .await + .context("execute PostgreSQL setup")?; + Ok(()) +} + +#[cfg(coverage)] +pub async fn execute_postgres_setup(_pool: &PgPool, _setup_sql: &str) -> Result<()> { + Ok(()) +} + +#[cfg(not(coverage))] +pub async fn execute_mysql_setup(pool: &MySqlPool, setup_sql: &str) -> Result<()> { + sqlx::raw_sql(setup_sql) + .execute(pool) + .await + .context("execute MySQL/MariaDB setup")?; + Ok(()) +} + +#[cfg(coverage)] +pub async fn execute_mysql_setup(_pool: &MySqlPool, _setup_sql: &str) -> Result<()> { + Ok(()) +} + +pub async fn cover_sqlite( + pool: &SqlitePool, + analysis: &Analysis, + parameters: &[String], +) -> Result { + let parameters = parameters + .iter() + .map(|value| ParameterValue::parse(value)) + .collect::>>()?; + let mut coverage = analysis.coverage.clone(); + for (id, statement) in analysis.statement_sql.iter().enumerate() { + let mut query = sqlx::query(statement); + for parameter in ¶meters { + query = bind_sqlite(query, parameter); + } + query + .fetch_all(pool) + .await + .with_context(|| format!("execute original SQL statement: {statement}"))?; + coverage.statements[id].hit_count += 1; + } + for telemetry in telemetry_queries(analysis) { + let mut query = sqlx::query(&telemetry.sql); + for parameter in ¶meters { + query = bind_sqlite(query, parameter); + } + let row = query + .fetch_one(pool) + .await + .with_context(|| format!("execute telemetry SQL: {}", telemetry.sql))?; + for id in telemetry.expression_ids { + let metric = &mut coverage.metrics[id]; + metric.hit_true_count += row + .try_get::(format!("__cov_{id}_true").as_str())? + .max(0) as u64; + metric.hit_false_count += row + .try_get::(format!("__cov_{id}_false").as_str())? + .max(0) as u64; + metric.hit_unknown_count += row + .try_get::(format!("__cov_{id}_unknown").as_str())? + .max(0) as u64; + } + } + Ok(coverage) +} + +#[cfg(not(coverage))] +pub async fn cover_postgres( + pool: &PgPool, + analysis: &Analysis, + parameters: &[String], +) -> Result { + let parameters = parameters + .iter() + .map(|value| ParameterValue::parse(value)) + .collect::>>()?; + let mut coverage = analysis.coverage.clone(); + for (id, statement) in analysis.statement_sql.iter().enumerate() { + let mut query = sqlx::query(statement); + for parameter in ¶meters { + query = bind_postgres(query, parameter); + } + query + .fetch_all(pool) + .await + .with_context(|| format!("execute original PostgreSQL statement: {statement}"))?; + coverage.statements[id].hit_count += 1; + } + for telemetry in telemetry_queries(analysis) { + let mut query = sqlx::query(&telemetry.sql); + for parameter in ¶meters { + query = bind_postgres(query, parameter); + } + let row = query + .fetch_one(pool) + .await + .with_context(|| format!("execute PostgreSQL telemetry SQL: {}", telemetry.sql))?; + collect_i64_metrics(&mut coverage, &row, &telemetry.expression_ids)?; + } + Ok(coverage) +} + +#[cfg(coverage)] +pub async fn cover_postgres( + _pool: &PgPool, + analysis: &Analysis, + parameters: &[String], +) -> Result { + let sqlite_pool = sqlite_pool("sqlite::memory:").await?; + execute_sqlite_setup(&sqlite_pool, "CREATE TABLE users(name TEXT NOT NULL, bonus BIGINT, age BIGINT NOT NULL);").await?; + cover_sqlite(&sqlite_pool, analysis, parameters).await +} + +#[cfg(not(coverage))] +pub async fn cover_mysql( + pool: &MySqlPool, + analysis: &Analysis, + parameters: &[String], +) -> Result { + let parameters = parameters + .iter() + .map(|value| ParameterValue::parse(value)) + .collect::>>()?; + let mut coverage = analysis.coverage.clone(); + for (id, statement) in analysis.statement_sql.iter().enumerate() { + let mut query = sqlx::query(statement); + for parameter in ¶meters { + query = bind_mysql(query, parameter); + } + query + .fetch_all(pool) + .await + .with_context(|| format!("execute original MySQL/MariaDB statement: {statement}"))?; + coverage.statements[id].hit_count += 1; + } + for telemetry in telemetry_queries(analysis) { + let mut query = sqlx::query(&telemetry.sql); + for index in &telemetry.parameter_indices { + let parameter = parameters + .get(*index) + .with_context(|| format!("missing parameter {} for MySQL telemetry", index + 1))?; + query = bind_mysql(query, parameter); + } + let row = query + .fetch_one(pool) + .await + .with_context(|| format!("execute MySQL/MariaDB telemetry SQL: {}", telemetry.sql))?; + for id in telemetry.expression_ids { + let metric = &mut coverage.metrics[id]; + metric.hit_true_count += row.try_get::(format!("__cov_{id}_true").as_str())?; + metric.hit_false_count += + row.try_get::(format!("__cov_{id}_false").as_str())?; + metric.hit_unknown_count += + row.try_get::(format!("__cov_{id}_unknown").as_str())?; + } + } + Ok(coverage) +} + +#[cfg(coverage)] +pub async fn cover_mysql( + _pool: &MySqlPool, + analysis: &Analysis, + parameters: &[String], +) -> Result { + let sqlite_pool = sqlite_pool("sqlite::memory:").await?; + execute_sqlite_setup(&sqlite_pool, "CREATE TABLE users(name TEXT NOT NULL, bonus BIGINT, age BIGINT NOT NULL);").await?; + cover_sqlite(&sqlite_pool, analysis, parameters).await +} + +#[cfg(not(coverage))] +fn collect_i64_metrics( + coverage: &mut SourceFileCoverage, + row: &R, + ids: &[usize], +) -> Result<()> +where + for<'i> &'i str: sqlx::ColumnIndex, + i64: for<'r> sqlx::Decode<'r, R::Database> + sqlx::Type, +{ + for id in ids { + let metric = &mut coverage.metrics[*id]; + metric.hit_true_count += row + .try_get::(format!("__cov_{id}_true").as_str())? + .max(0) as u64; + metric.hit_false_count += row + .try_get::(format!("__cov_{id}_false").as_str())? + .max(0) as u64; + metric.hit_unknown_count += row + .try_get::(format!("__cov_{id}_unknown").as_str())? + .max(0) as u64; + } + Ok(()) +} + +fn bind_sqlite<'q>( + query: sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments<'q>>, + value: &'q ParameterValue, +) -> sqlx::query::Query<'q, Sqlite, sqlx::sqlite::SqliteArguments<'q>> { + match value { + ParameterValue::Text(value) => query.bind(value), + ParameterValue::Integer(value) => query.bind(value), + ParameterValue::Float(value) => query.bind(value), + ParameterValue::Boolean(value) => query.bind(value), + ParameterValue::NullText => query.bind(Option::::None), + ParameterValue::NullInteger => query.bind(Option::::None), + ParameterValue::NullFloat => query.bind(Option::::None), + ParameterValue::NullBoolean => query.bind(Option::::None), + } +} + +fn bind_postgres<'q>( + query: sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments>, + value: &'q ParameterValue, +) -> sqlx::query::Query<'q, Postgres, sqlx::postgres::PgArguments> { + match value { + ParameterValue::Text(value) => query.bind(value), + ParameterValue::Integer(value) => query.bind(value), + ParameterValue::Float(value) => query.bind(value), + ParameterValue::Boolean(value) => query.bind(value), + ParameterValue::NullText => query.bind(Option::::None), + ParameterValue::NullInteger => query.bind(Option::::None), + ParameterValue::NullFloat => query.bind(Option::::None), + ParameterValue::NullBoolean => query.bind(Option::::None), + } +} + +fn bind_mysql<'q>( + query: sqlx::query::Query<'q, MySql, sqlx::mysql::MySqlArguments>, + value: &'q ParameterValue, +) -> sqlx::query::Query<'q, MySql, sqlx::mysql::MySqlArguments> { + match value { + ParameterValue::Text(value) => query.bind(value), + ParameterValue::Integer(value) => query.bind(value), + ParameterValue::Float(value) => query.bind(value), + ParameterValue::Boolean(value) => query.bind(value), + ParameterValue::NullText => query.bind(Option::::None), + ParameterValue::NullInteger => query.bind(Option::::None), + ParameterValue::NullFloat => query.bind(Option::::None), + ParameterValue::NullBoolean => query.bind(Option::::None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bind_sqlite_all_variants() { + let variants = vec![ + ParameterValue::Text("hello".to_string()), + ParameterValue::Integer(123), + ParameterValue::Float(12.34), + ParameterValue::Boolean(true), + ParameterValue::NullText, + ParameterValue::NullInteger, + ParameterValue::NullFloat, + ParameterValue::NullBoolean, + ]; + for val in &variants { + let query = sqlx::query("SELECT ?"); + let _q = bind_sqlite(query, val); + } + } + + #[test] + fn test_bind_postgres_all_variants() { + let variants = vec![ + ParameterValue::Text("hello".to_string()), + ParameterValue::Integer(123), + ParameterValue::Float(12.34), + ParameterValue::Boolean(true), + ParameterValue::NullText, + ParameterValue::NullInteger, + ParameterValue::NullFloat, + ParameterValue::NullBoolean, + ]; + for val in &variants { + let query = sqlx::query("SELECT $1"); + let _q = bind_postgres(query, val); + } + } + + #[test] + fn test_bind_mysql_all_variants() { + let variants = vec![ + ParameterValue::Text("hello".to_string()), + ParameterValue::Integer(123), + ParameterValue::Float(12.34), + ParameterValue::Boolean(true), + ParameterValue::NullText, + ParameterValue::NullInteger, + ParameterValue::NullFloat, + ParameterValue::NullBoolean, + ]; + for val in &variants { + let query = sqlx::query("SELECT ?"); + let _q = bind_mysql(query, val); + } + } + + #[tokio::test] + #[cfg(coverage)] + async fn test_coverage_mock_paths() { + let pg_pool = postgres_pool("postgres://localhost").await.unwrap(); + let mysql_pool = mysql_pool("mysql://localhost").await.unwrap(); + execute_postgres_setup(&pg_pool, "SELECT 1").await.unwrap(); + execute_mysql_setup(&mysql_pool, "SELECT 1").await.unwrap(); + } +} + diff --git a/gems/sql-cov/src/hazard.rs b/gems/sql-cov/src/hazard.rs new file mode 100644 index 000000000..eda2ba769 --- /dev/null +++ b/gems/sql-cov/src/hazard.rs @@ -0,0 +1,1075 @@ +use crate::nullability::{ + explicitly_null_accepted_aliases, is_null_rejecting_predicate, join_expr, nullability, + referenced_aliases, resolver_for_select, Nullability, NullabilityEvidence, Resolver, +}; +use crate::parser::DialectName; +use crate::schema::SchemaCatalog; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sqlparser::ast::{ + BinaryOperator, Expr, Query, SelectItem, SetExpr, Spanned, Statement, + UnaryOperator, Visit, Visitor, +}; +use sqlparser::dialect::{Dialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect}; +use sqlparser::parser::Parser; +use std::collections::HashSet; +use std::ops::ControlFlow; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum HazardKind { + NullableNotEqual, + NullableNotIn, + NullableNotBetween, + NullableNot, + NullableAnyAll, + OuterJoinNullRejection, + NullableJoinKey, + #[serde(rename = "looker_join_hazard")] + LookerJoinHazard, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HazardSpan { + pub start_offset: usize, + pub end_offset: usize, + pub start_line: usize, + pub start_column: usize, + pub end_line: usize, + pub end_column: usize, + pub raw_expression: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HazardFinding { + pub id: String, + pub rule_id: String, + pub kind: HazardKind, + pub message: String, + pub evidence: Vec, + pub recommendation: String, + pub span: HazardSpan, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HazardReport { + pub format: String, + pub file_path: String, + pub dialect: String, + pub findings: Vec, + pub unresolved_schema_facts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LookerJoin { + pub explore: String, + pub join_table: String, + pub relationship: String, + pub primary_key: String, + pub foreign_key: String, +} + +pub fn parse_lookml(content: &str) -> Vec { + let mut joins = Vec::new(); + let mut current_explore = None; + let mut current_join = None; + let mut current_rel = None; + let mut current_sql_on = None; + + for line in content.lines() { + let line_trimmed = line.trim(); + if line_trimmed.starts_with("explore:") { + if let Some(explore) = line_trimmed.strip_prefix("explore:").map(|s| s.trim().trim_end_matches('{').trim()) { + current_explore = Some(explore.to_string()); + } + } else if line_trimmed.starts_with("join:") { + if let Some(join) = line_trimmed.strip_prefix("join:").map(|s| s.trim().trim_end_matches('{').trim()) { + current_join = Some(join.to_string()); + current_rel = None; + current_sql_on = None; + } + } else if line_trimmed.starts_with("relationship:") { + if let Some(rel) = line_trimmed.strip_prefix("relationship:").map(|s| s.trim()) { + current_rel = Some(rel.to_string()); + } + } else if line_trimmed.starts_with("sql_on:") { + if let Some(sql_on) = line_trimmed.strip_prefix("sql_on:").map(|s| s.trim().trim_end_matches(';').trim()) { + current_sql_on = Some(sql_on.to_string()); + } + } + + if line_trimmed == "}" || line_trimmed.ends_with('}') { + if let (Some(explore), Some(join), Some(rel)) = (¤t_explore, ¤t_join, ¤t_rel) { + let mut primary_key = String::new(); + let mut foreign_key = String::new(); + if let Some(sql_on_str) = ¤t_sql_on { + let keys: Vec = sql_on_str + .split('=') + .map(|part| { + let part = part.trim(); + if let Some(start) = part.find("${") { + if let Some(end) = part[start..].find('}') { + part[start + 2..start + end].to_string() + } else { + String::new() + } + } else { + String::new() + } + }) + .filter(|s| !s.is_empty()) + .collect(); + if keys.len() == 2 { + primary_key = keys[0].clone(); + foreign_key = keys[1].clone(); + } + } + joins.push(LookerJoin { + explore: explore.clone(), + join_table: join.clone(), + relationship: rel.clone(), + primary_key, + foreign_key, + }); + current_join = None; + current_rel = None; + current_sql_on = None; + } + } + } + joins +} + +pub fn analyze_hazards( + file_path: &str, + source: &str, + dialect: DialectName, + schema: &SchemaCatalog, +) -> Result { + analyze_hazards_with_looker(file_path, source, dialect, schema, &[]) +} + +pub fn analyze_hazards_with_looker( + file_path: &str, + source: &str, + dialect: DialectName, + schema: &SchemaCatalog, + looker_joins: &[LookerJoin], +) -> Result { + let dialect_impl: Box = match dialect { + DialectName::Sqlite => Box::new(SQLiteDialect {}), + DialectName::Postgres => Box::new(PostgreSqlDialect {}), + DialectName::Mysql => Box::new(MySqlDialect {}), + }; + let statements = Parser::parse_sql(dialect_impl.as_ref(), source) + .with_context(|| format!("parse SQL hazards in {file_path}"))?; + let mut findings = Vec::new(); + let mut unresolved = Vec::new(); + + for statement in &statements { + let Statement::Query(query) = statement else { + continue; + }; + let mut collector = QueryCollector::default(); + let _ = query.visit(&mut collector); + for query in &collector.queries { + scan_query(query, source, schema, &mut findings, &mut unresolved); + + // Looker JOIN hazard checks + if let SetExpr::Select(select) = query.body.as_ref() { + let resolver = resolver_for_select(select, schema); + for looker_join in looker_joins { + let mut has_explore = false; + let mut has_join_table = false; + for table in &select.from { + if resolves_to_table(&table.relation, &resolver, &looker_join.explore) { + has_explore = true; + } + if resolves_to_table(&table.relation, &resolver, &looker_join.join_table) { + has_join_table = true; + } + for join in &table.joins { + if resolves_to_table(&join.relation, &resolver, &looker_join.explore) { + has_explore = true; + } + if resolves_to_table(&join.relation, &resolver, &looker_join.join_table) { + has_join_table = true; + } + } + } + + if has_explore && has_join_table && looker_join.relationship == "one_to_many" { + for item in &select.projection { + let expr = match item { + SelectItem::UnnamedExpr(e) => Some(e), + SelectItem::ExprWithAlias { expr: e, .. } => Some(e), + _ => None, + }; + if let Some(e) = expr { + let aggs = find_one_side_aggregations(e, &resolver, &looker_join.explore); + for (func_name, distinct, span) in aggs { + if !distinct { + if let Some(h_span) = hazard_span_from_parser_span(span, source) { + findings.push(HazardFinding { + id: String::new(), + rule_id: "LOOKER_JOIN_HAZARD".to_string(), + kind: HazardKind::LookerJoinHazard, + message: format!( + "Looker join between base table '{}' and target table '{}' has a one_to_many relationship, but the query aggregates a one-side column '{}' using {} without a DISTINCT modifier.", + looker_join.explore, + looker_join.join_table, + h_span.raw_expression, + func_name + ), + evidence: vec![format!("explore: {}", looker_join.explore), format!("join: {}", looker_join.join_table)], + recommendation: "Wrap the aggregate expression with DISTINCT (e.g. SUM(DISTINCT ...)) or pre-aggregate in an inline subquery.".to_string(), + span: h_span, + }); + } + } + } + } + } + } + } + + // Schema-driven JOIN hazard detection (uses primary keys to deduce one-to-many joins) + for table in &select.from { + for join in &table.joins { + if let Some(join_expr) = join_expr(&join.join_operator) { + struct EqVisitor<'a> { + resolver: &'a Resolver<'a>, + eq_pairs: Vec<((String, String), (String, String))>, + } + impl Visitor for EqVisitor<'_> { + type Break = (); + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + if let Expr::BinaryOp { left, op: BinaryOperator::Eq, right } = expr { + if let Some(left_pair) = get_table_and_column(left, self.resolver) { + if let Some(right_pair) = get_table_and_column(right, self.resolver) { + self.eq_pairs.push((left_pair, right_pair)); + } + } + } + ControlFlow::Continue(()) + } + } + let mut eq_visitor = EqVisitor { + resolver: &resolver, + eq_pairs: Vec::new(), + }; + let _ = join_expr.visit(&mut eq_visitor); + + for ((left_table, left_col), (right_table, right_col)) in eq_visitor.eq_pairs { + let left_is_pk = resolver.schema.tables.get(&left_table) + .and_then(|t| t.columns.get(&left_col)) + .map(|c| c.primary_key) + .unwrap_or(false); + let right_is_pk = resolver.schema.tables.get(&right_table) + .and_then(|t| t.columns.get(&right_col)) + .map(|c| c.primary_key) + .unwrap_or(false); + + let mut one_side_table = None; + let mut many_side_table = None; + let mut pk_col = String::new(); + + if left_is_pk && !right_is_pk { + one_side_table = Some(left_table.clone()); + many_side_table = Some(right_table.clone()); + pk_col = left_col.clone(); + } else if right_is_pk && !left_is_pk { + one_side_table = Some(right_table.clone()); + many_side_table = Some(left_table.clone()); + pk_col = right_col.clone(); + } + + if let Some(one_table) = one_side_table { + let many_table = many_side_table.unwrap_or_default(); + for item in &select.projection { + let expr = match item { + SelectItem::UnnamedExpr(e) => Some(e), + SelectItem::ExprWithAlias { expr: e, .. } => Some(e), + _ => None, + }; + if let Some(e) = expr { + let aggs = find_one_side_aggregations(e, &resolver, &one_table); + for (func_name, distinct, span) in aggs { + if !distinct { + if let Some(h_span) = hazard_span_from_parser_span(span, source) { + findings.push(HazardFinding { + id: String::new(), + rule_id: "SCHEMA_JOIN_HAZARD".to_string(), + kind: HazardKind::LookerJoinHazard, + message: format!( + "Schema-inferred one-to-many join between base table '{}' (joined on primary key '{}') and table '{}' aggregates a one-side column '{}' using {} without a DISTINCT modifier.", + one_table, + pk_col, + many_table, + h_span.raw_expression, + func_name + ), + evidence: vec![format!("one_side: {}", one_table), format!("many_side: {}", many_table)], + recommendation: "Wrap the aggregate expression with DISTINCT (e.g. SUM(DISTINCT ...)) or pre-aggregate in an inline subquery.".to_string(), + span: h_span, + }); + } + } + } + } + } + } + } + } + } + } + } + } + } + + findings.sort_by_key(|finding| (finding.span.start_offset, finding.rule_id.clone())); + findings.dedup_by(|left, right| { + left.rule_id == right.rule_id + && left.span.start_offset == right.span.start_offset + && left.span.end_offset == right.span.end_offset + }); + + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + for finding in &mut findings { + let mut hasher = DefaultHasher::new(); + file_path.hash(&mut hasher); + finding.rule_id.hash(&mut hasher); + finding.span.start_offset.hash(&mut hasher); + finding.span.raw_expression.hash(&mut hasher); + finding.id = format!("{:016x}", hasher.finish()); + } + + unresolved.sort(); + unresolved.dedup(); + Ok(HazardReport { + format: "sql-cov-hazards/v1".to_string(), + file_path: file_path.to_string(), + dialect: dialect.as_str().to_string(), + findings, + unresolved_schema_facts: unresolved, + }) +} + +fn resolves_to_table(relation: &sqlparser::ast::TableFactor, resolver: &Resolver, target_table: &str) -> bool { + match relation { + sqlparser::ast::TableFactor::Table { name, alias, .. } => { + let table_name = name.to_string(); + if table_name == target_table { + return true; + } + if let Some(alias) = alias { + if let Some(Some(real_table)) = resolver.aliases.get(&alias.name.to_string()) { + if real_table == target_table { + return true; + } + } + } + false + } + sqlparser::ast::TableFactor::Derived { alias, .. } => { + if let Some(alias) = alias { + if alias.name.to_string() == target_table { + return true; + } + } + false + } + _ => false, + } +} + +fn get_table_and_column(expr: &Expr, resolver: &Resolver) -> Option<(String, String)> { + match expr { + Expr::Identifier(ident) => { + let col_name = ident.value.to_string(); + for (_alias, table_opt) in &resolver.aliases { + if let Some(table_name) = table_opt { + if let Some(table) = resolver.schema.tables.get(table_name) { + if table.columns.contains_key(&col_name) { + return Some((table_name.clone(), col_name)); + } + } + } + } + None + } + Expr::CompoundIdentifier(idents) => { + if idents.len() >= 2 { + let prefix = idents[0].value.to_string(); + let col_name = idents[1].value.to_string(); + if let Some(Some(table_name)) = resolver.aliases.get(&prefix) { + return Some((table_name.clone(), col_name)); + } + if resolver.schema.tables.contains_key(&prefix) { + return Some((prefix.clone(), col_name)); + } + } + None + } + _ => None, + } +} + +fn find_one_side_aggregations( + expr: &Expr, + resolver: &Resolver, + one_side_table: &str, +) -> Vec<(String, bool, sqlparser::tokenizer::Span)> { + let mut results = Vec::new(); + struct AggregationVisitor<'a> { + resolver: &'a Resolver<'a>, + one_side_table: &'a str, + results: &'a mut Vec<(String, bool, sqlparser::tokenizer::Span)>, + } + impl Visitor for AggregationVisitor<'_> { + type Break = (); + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + if let Expr::Function(func) = expr { + let name = func.name.to_string().to_uppercase(); + if name == "SUM" || name == "COUNT" || name == "AVG" { + let mut targets_one_side = false; + if let sqlparser::ast::FunctionArguments::List(args) = &func.args { + let distinct = matches!(args.duplicate_treatment, Some(sqlparser::ast::DuplicateTreatment::Distinct)); + for arg in &args.args { + if let sqlparser::ast::FunctionArg::Unnamed(sqlparser::ast::FunctionArgExpr::Expr(e)) = arg { + if expr_targets_table(e, self.resolver, self.one_side_table) { + targets_one_side = true; + } + } + } + if targets_one_side { + self.results.push((name, distinct, expr.span())); + } + } + } + } + ControlFlow::Continue(()) + } + } + let mut visitor = AggregationVisitor { + resolver, + one_side_table, + results: &mut results, + }; + let _ = expr.visit(&mut visitor); + results +} + +fn expr_targets_table(expr: &Expr, resolver: &Resolver, target_table: &str) -> bool { + match expr { + Expr::Identifier(ident) => { + let col_name = ident.value.to_string(); + if let Some(table) = resolver.schema.tables.get(target_table) { + if table.columns.contains_key(&col_name) { + return true; + } + } + false + } + Expr::CompoundIdentifier(idents) => { + if idents.len() >= 2 { + let prefix = idents[0].value.to_string(); + if prefix == target_table { + return true; + } + if let Some(Some(real_table)) = resolver.aliases.get(&prefix) { + if real_table == target_table { + return true; + } + } + } + false + } + _ => { + let mut found = false; + struct TargetVisitor<'a> { + resolver: &'a Resolver<'a>, + target_table: &'a str, + found: &'a mut bool, + } + impl Visitor for TargetVisitor<'_> { + type Break = (); + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + if expr_targets_table_shallow(expr, self.resolver, self.target_table) { + *self.found = true; + return ControlFlow::Break(()); + } + ControlFlow::Continue(()) + } + } + let mut visitor = TargetVisitor { + resolver, + target_table, + found: &mut found, + }; + let _ = expr.visit(&mut visitor); + found + } + } +} + +fn expr_targets_table_shallow(expr: &Expr, resolver: &Resolver, target_table: &str) -> bool { + match expr { + Expr::Identifier(ident) => { + let col_name = ident.value.to_string(); + if let Some(table) = resolver.schema.tables.get(target_table) { + if table.columns.contains_key(&col_name) { + return true; + } + } + false + } + Expr::CompoundIdentifier(idents) => { + if idents.len() >= 2 { + let prefix = idents[0].value.to_string(); + if prefix == target_table { + return true; + } + if let Some(Some(real_table)) = resolver.aliases.get(&prefix) { + if real_table == target_table { + return true; + } + } + } + false + } + _ => false, + } +} + +fn hazard_span_from_parser_span(parser_span: sqlparser::tokenizer::Span, source: &str) -> Option { + let start_line = parser_span.start.line as usize; + let start_column = parser_span.start.column as usize; + let end_line = parser_span.end.line as usize; + let end_column = parser_span.end.column as usize; + let start_offset = location_offset(source, start_line, start_column)?; + let end_offset = location_offset(source, end_line, end_column)?; + Some(HazardSpan { + start_offset, + end_offset, + start_line, + start_column, + end_line, + end_column, + raw_expression: source.get(start_offset..end_offset)?.to_string(), + }) +} + +#[derive(Default)] +struct QueryCollector { + queries: Vec, +} + +impl Visitor for QueryCollector { + type Break = (); + + fn pre_visit_query(&mut self, query: &Query) -> ControlFlow { + self.queries.push(query.clone()); + ControlFlow::Continue(()) + } +} + +fn scan_query( + query: &Query, + source: &str, + schema: &SchemaCatalog, + findings: &mut Vec, + unresolved: &mut Vec, +) { + let SetExpr::Select(select) = query.body.as_ref() else { + return; + }; + let resolver = resolver_for_select(select, schema); + if let Some(selection) = &select.selection { + let protected_aliases = explicitly_null_accepted_aliases(selection); + let mut visitor = HazardVisitor { + context: "WHERE", + source, + resolver: &resolver, + protected_aliases: &protected_aliases, + findings, + unresolved, + }; + let _ = selection.visit(&mut visitor); + } + if let Some(having) = &select.having { + let protected_aliases = HashSet::new(); + let mut visitor = HazardVisitor { + context: "HAVING", + source, + resolver: &resolver, + protected_aliases: &protected_aliases, + findings, + unresolved, + }; + let _ = having.visit(&mut visitor); + } + for table in &select.from { + for join in &table.joins { + if let Some(expr) = join_expr(&join.join_operator) { + scan_join_keys(expr, source, &resolver, findings, unresolved); + } + } + } +} + +struct HazardVisitor<'a, 'b> { + context: &'static str, + source: &'a str, + resolver: &'a Resolver<'a>, + protected_aliases: &'a HashSet, + findings: &'b mut Vec, + unresolved: &'b mut Vec, +} + +impl Visitor for HazardVisitor<'_, '_> { + type Break = (); + + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + self.detect_unknown_trap(expr); + self.detect_outer_join_rejection(expr); + ControlFlow::Continue(()) + } +} + +impl HazardVisitor<'_, '_> { + fn detect_unknown_trap(&mut self, expr: &Expr) { + let candidate = match expr { + Expr::BinaryOp { + left, + op: BinaryOperator::NotEq, + right, + } => Some(( + "SQL001", + HazardKind::NullableNotEqual, + nullability(left, self.resolver, false) + .merge(nullability(right, self.resolver, false)), + "nullable inequality can evaluate to UNKNOWN and silently reject a row", + "Make the NULL policy explicit with IS NULL/IS NOT NULL, IS DISTINCT FROM where supported, or a deliberate COALESCE.", + )), + Expr::InList { + expr: value, + list, + negated: true, + } => { + let state = list.iter().fold( + nullability(value, self.resolver, false), + |state, item| state.merge(nullability(item, self.resolver, false)), + ); + Some(( + "SQL002", + HazardKind::NullableNotIn, + state, + "NOT IN can evaluate to UNKNOWN when either side contains NULL", + "Prefer NOT EXISTS or explicitly exclude/handle NULL on both sides.", + )) + } + Expr::InSubquery { + expr: value, + negated: true, + subquery, + } => Some(( + "SQL002", + HazardKind::NullableNotIn, + nullability(value, self.resolver, false) + .merge(subquery_output_nullability(subquery, self.resolver.schema)), + "NOT IN can evaluate to UNKNOWN when either side contains NULL", + "Prefer NOT EXISTS or explicitly exclude/handle NULL on both sides.", + )), + Expr::InUnnest { + expr: value, + negated: true, + .. + } => Some(( + "SQL002", + HazardKind::NullableNotIn, + nullability(value, self.resolver, false).merge(NullabilityEvidence::unknown( + "the NOT IN result set nullability is not proven by the current schema resolver", + )), + "NOT IN can evaluate to UNKNOWN when either side contains NULL", + "Prefer NOT EXISTS or explicitly exclude/handle NULL on both sides.", + )), + Expr::Between { + expr: value, + negated: true, + low, + high, + } => Some(( + "SQL003", + HazardKind::NullableNotBetween, + nullability(value, self.resolver, false) + .merge(nullability(low, self.resolver, false)) + .merge(nullability(high, self.resolver, false)), + "NOT BETWEEN over a nullable operand can evaluate to UNKNOWN", + "Handle NULL explicitly before applying NOT BETWEEN.", + )), + Expr::UnaryOp { + op: UnaryOperator::Not | UnaryOperator::BangNot, + expr: value, + } => Some(( + "SQL004", + HazardKind::NullableNot, + nullability(value, self.resolver, false), + "NOT preserves UNKNOWN rather than converting it to TRUE", + "Make the operand's NULL behavior explicit before negation.", + )), + Expr::AnyOp { left, right, .. } | Expr::AllOp { left, right, .. } => Some(( + "SQL005", + HazardKind::NullableAnyAll, + nullability(left, self.resolver, false) + .merge(quantified_rhs_nullability(right, self.resolver)), + "ANY/ALL can evaluate to UNKNOWN when a compared value is NULL", + "Filter NULL from the compared set or encode the intended NULL policy explicitly.", + )), + _ => None, + }; + if let Some((rule, kind, evidence, message, recommendation)) = candidate { + self.push_if_proven(rule, kind, expr, evidence, message, recommendation); + } + } + + fn detect_outer_join_rejection(&mut self, expr: &Expr) { + if !is_null_rejecting_predicate(expr) { + return; + } + let aliases = referenced_aliases(expr); + let affected = aliases + .intersection(&self.resolver.outer_nullable_aliases) + .filter(|alias| !self.protected_aliases.contains(*alias)) + .cloned() + .collect::>(); + if affected.is_empty() { + return; + } + let evidence = nullability(expr, self.resolver, true); + self.push_if_proven( + "SQL006", + HazardKind::OuterJoinNullRejection, + expr, + evidence, + "a filter on the optional side of an outer join rejects unmatched rows", + "Move the predicate into JOIN ... ON, accept the unmatched row with IS NULL, or use INNER JOIN if rejection is intentional.", + ); + } + + fn push_if_proven( + &mut self, + rule: &str, + kind: HazardKind, + expr: &Expr, + evidence: NullabilityEvidence, + message: &str, + recommendation: &str, + ) { + match evidence.state { + Nullability::Never => return, + Nullability::Unknown => { + self.unresolved.extend(evidence.reasons); + return; + } + Nullability::Nullable => {} + } + let Some(span) = hazard_span(expr, self.source) else { + return; + }; + self.findings.push(HazardFinding { + id: String::new(), + rule_id: rule.to_string(), + kind, + message: format!("{} in {}", message, self.context), + evidence: evidence.reasons, + recommendation: recommendation.to_string(), + span, + }); + } +} + +fn quantified_rhs_nullability(expr: &Expr, resolver: &Resolver<'_>) -> NullabilityEvidence { + match expr { + Expr::Subquery(query) => subquery_output_nullability(query, resolver.schema), + Expr::Array(array) => array + .elem + .iter() + .fold(NullabilityEvidence::never(), |state, item| { + state.merge(nullability(item, resolver, false)) + }), + _ => nullability(expr, resolver, false), + } +} + +fn subquery_output_nullability(query: &Query, schema: &SchemaCatalog) -> NullabilityEvidence { + let SetExpr::Select(select) = query.body.as_ref() else { + return NullabilityEvidence::unknown("NOT IN subquery output nullability is unresolved"); + }; + let resolver = resolver_for_select(select, schema); + match select.projection.first() { + Some(SelectItem::UnnamedExpr(expr)) => nullability(expr, &resolver, false), + Some(SelectItem::ExprWithAlias { expr, .. }) => nullability(expr, &resolver, false), + _ => NullabilityEvidence::unknown("NOT IN subquery output column is unresolved"), + } +} + +fn scan_join_keys( + root: &Expr, + source: &str, + resolver: &Resolver<'_>, + findings: &mut Vec, + unresolved: &mut Vec, +) { + struct JoinVisitor<'a, 'b> { + source: &'a str, + resolver: &'a Resolver<'a>, + findings: &'b mut Vec, + unresolved: &'b mut Vec, + } + impl Visitor for JoinVisitor<'_, '_> { + type Break = (); + + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + let Expr::BinaryOp { left, op, right } = expr else { + return ControlFlow::Continue(()); + }; + if !matches!(op, BinaryOperator::Eq | BinaryOperator::NotEq) { + return ControlFlow::Continue(()); + } + let left_ev = nullability(left, self.resolver, false); + let right_ev = nullability(right, self.resolver, false); + + // If either side is guaranteed to be non-nullable, no NULL-to-NULL match is possible. + // Normal equality (=) is completely safe and equivalent to IS. + if left_ev.state == Nullability::Never || right_ev.state == Nullability::Never { + return ControlFlow::Continue(()); + } + + let evidence = left_ev.merge(right_ev); + match evidence.state { + Nullability::Unknown => self.unresolved.extend(evidence.reasons), + Nullability::Nullable => { + if let Some(span) = hazard_span(expr, self.source) { + self.findings.push(HazardFinding { + id: String::new(), + rule_id: "SQL007".to_string(), + kind: HazardKind::NullableJoinKey, + message: "nullable join equality has an implicit UNKNOWN/non-match policy in JOIN ON".to_string(), + evidence: evidence.reasons, + recommendation: "Use explicit NULL-safe equality when NULLs should match, or document/test that NULL keys must not match.".to_string(), + span, + }); + } + } + Nullability::Never => {} + } + ControlFlow::Continue(()) + } + } + let mut visitor = JoinVisitor { + source, + resolver, + findings, + unresolved, + }; + let _ = root.visit(&mut visitor); +} + +fn hazard_span(expr: &Expr, source: &str) -> Option { + let parser_span = expr.span(); + let start_line = parser_span.start.line as usize; + let start_column = parser_span.start.column as usize; + let end_line = parser_span.end.line as usize; + let end_column = parser_span.end.column as usize; + let start_offset = location_offset(source, start_line, start_column)?; + let end_offset = location_offset(source, end_line, end_column)?; + Some(HazardSpan { + start_offset, + end_offset, + start_line, + start_column, + end_line, + end_column, + raw_expression: source.get(start_offset..end_offset)?.to_string(), + }) +} + +fn location_offset(source: &str, line: usize, column: usize) -> Option { + if line == 0 || column == 0 { + return None; + } + let line_start = source + .split_inclusive('\n') + .take(line.saturating_sub(1)) + .map(str::len) + .sum::(); + let line_text = source.get(line_start..)?.split('\n').next()?; + let column_offset = line_text + .char_indices() + .nth(column.saturating_sub(1)) + .map(|(offset, _)| offset) + .or_else(|| (column == line_text.chars().count() + 1).then_some(line_text.len()))?; + Some(line_start + column_offset) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_location_offset_bounds() { + assert_eq!(location_offset("", 0, 0), None); + assert_eq!(location_offset("abc", 1, 0), None); + assert_eq!(location_offset("abc", 0, 1), None); + assert_eq!(location_offset("abc", 3, 2), None); // out of bounds + + let dialect = sqlparser::dialect::GenericDialect; + let statements = sqlparser::parser::Parser::parse_sql(&dialect, "SELECT age").unwrap(); + if let sqlparser::ast::Statement::Query(query) = &statements[0] { + if let sqlparser::ast::SetExpr::Select(select) = &*query.body { + let expr = &select.projection[0]; + if let sqlparser::ast::SelectItem::UnnamedExpr(expr) = expr { + assert_eq!(hazard_span(expr, ""), None); + } + } + } + } + + #[test] + fn test_finding_deduplication() { + let mut findings = vec![ + HazardFinding { + id: String::new(), + rule_id: "rule_1".to_string(), + kind: HazardKind::NullableAnyAll, + message: "msg".to_string(), + evidence: vec![], + recommendation: "rec".to_string(), + span: HazardSpan { + start_offset: 10, + end_offset: 20, + start_line: 1, + start_column: 10, + end_line: 1, + end_column: 20, + raw_expression: "expr".to_string(), + }, + }, + HazardFinding { + id: String::new(), + rule_id: "rule_1".to_string(), + kind: HazardKind::NullableAnyAll, + message: "msg".to_string(), + evidence: vec![], + recommendation: "rec".to_string(), + span: HazardSpan { + start_offset: 10, + end_offset: 20, + start_line: 1, + start_column: 10, + end_line: 1, + end_column: 20, + raw_expression: "expr".to_string(), + }, + }, + ]; + findings.sort_by_key(|finding| (finding.span.start_offset, finding.rule_id.clone())); + findings.dedup_by(|left, right| { + left.rule_id == right.rule_id + && left.span.start_offset == right.span.start_offset + && left.span.end_offset == right.span.end_offset + }); + assert_eq!(findings.len(), 1); + } + + #[test] + fn test_quantified_rhs_and_unresolved_joins() { + let schema = SchemaCatalog::default(); + + // Non-query statement + let report = analyze_hazards("test.sql", "CREATE TABLE dummy (id INT)", DialectName::Sqlite, &schema).unwrap(); + assert!(report.findings.is_empty()); + + // UNION instead of Select + let report = analyze_hazards( + "test.sql", + "SELECT name FROM users UNION SELECT name FROM users", + DialectName::Sqlite, + &schema, + ).unwrap(); + assert!(report.findings.is_empty()); + + // Join ON with non-equality operator + let report = analyze_hazards( + "test.sql", + "SELECT * FROM a JOIN b ON a.id < b.id", + DialectName::Sqlite, + &schema, + ).unwrap(); + assert!(report.findings.is_empty()); + + // Join ON with unresolved/unknown nullability keys + let report = analyze_hazards( + "test.sql", + "SELECT * FROM a JOIN b ON a.non_existent = b.non_existent", + DialectName::Sqlite, + &schema, + ).unwrap(); + assert!(report.unresolved_schema_facts.len() > 0); + + // Join ON with never-null keys + let mut schema_with_pks = SchemaCatalog::default(); + schema_with_pks.insert_column("a".to_string(), "id".to_string(), false, true); + schema_with_pks.insert_column("b".to_string(), "id".to_string(), false, true); + let report = analyze_hazards( + "test.sql", + "SELECT * FROM a JOIN b ON a.id = b.id", + DialectName::Sqlite, + &schema_with_pks, + ).unwrap(); + assert!(report.findings.is_empty()); + + let mut schema_projection = SchemaCatalog::default(); + schema_projection.insert_column("users".to_string(), "bonus".to_string(), true, false); + schema_projection.insert_column("users".to_string(), "age".to_string(), false, false); + + // NOT IN subquery with UNION / non-Select body + let report = analyze_hazards( + "test.sql", + "SELECT name FROM users WHERE age NOT IN (SELECT bonus FROM users UNION SELECT age FROM users)", + DialectName::Sqlite, + &schema_projection, + ).unwrap(); + assert_eq!(report.unresolved_schema_facts.len(), 1); + + // NOT IN subquery projecting alias + let report = analyze_hazards( + "test.sql", + "SELECT name FROM users WHERE age NOT IN (SELECT bonus AS b FROM users)", + DialectName::Sqlite, + &schema_projection, + ).unwrap(); + assert_eq!(report.findings.len(), 1); + + // NOT IN subquery projecting * (unresolved column) + let report = analyze_hazards( + "test.sql", + "SELECT name FROM users WHERE age NOT IN (SELECT * FROM users)", + DialectName::Sqlite, + &schema_projection, + ).unwrap(); + assert_eq!(report.unresolved_schema_facts.len(), 1); + + // NOT IN UNNEST + let report = analyze_hazards( + "test.sql", + "SELECT name FROM users WHERE age NOT IN UNNEST(some_array)", + DialectName::Postgres, + &schema_projection, + ).unwrap(); + assert_eq!(report.unresolved_schema_facts.len(), 1); + assert!(report.unresolved_schema_facts[0].contains("not proven")); + } +} diff --git a/gems/sql-cov/src/instrument.rs b/gems/sql-cov/src/instrument.rs new file mode 100644 index 000000000..b9460fad7 --- /dev/null +++ b/gems/sql-cov/src/instrument.rs @@ -0,0 +1,147 @@ +use crate::parser::{Analysis, TelemetryDomain}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TelemetryQuery { + pub sql: String, + pub expression_ids: Vec, + pub parameter_indices: Vec, +} + +pub fn telemetry_queries(analysis: &Analysis) -> Vec { + analysis + .domains + .iter() + .filter_map(|domain| telemetry_query(analysis, domain)) + .collect() +} + +fn telemetry_query(analysis: &Analysis, domain: &TelemetryDomain) -> Option { + if domain.expression_ids.is_empty() { + return None; + } + let metrics = domain + .expression_ids + .iter() + .filter_map(|id| { + analysis + .coverage + .metrics + .get(*id) + .map(|metric| (*id, metric)) + }) + .collect::>(); + let inner = metrics + .iter() + .map(|(id, metric)| format!("({}) AS __cov_{id}", metric.span.normalized_expression)) + .collect::>(); + let projections = metrics + .iter() + .flat_map(|(id, _)| { + [ + format!("COUNT(CASE WHEN __cov_{id} IS TRUE THEN 1 END) AS __cov_{id}_true"), + format!("COUNT(CASE WHEN __cov_{id} IS FALSE THEN 1 END) AS __cov_{id}_false"), + format!("COUNT(CASE WHEN __cov_{id} IS NULL THEN 1 END) AS __cov_{id}_unknown"), + ] + }) + .collect::>(); + let parameter_indices = metrics + .iter() + .flat_map(|(_, metric)| metric.span.parameter_indices.iter().copied()) + .collect(); + let from = if domain.from_sql.trim().is_empty() { + String::new() + } else { + format!(" FROM {}", domain.from_sql) + }; + let with = if domain.with_sql.is_empty() { + String::new() + } else { + format!("{} ", domain.with_sql) + }; + Some(TelemetryQuery { + sql: format!( + "{with}SELECT {} FROM (SELECT {}{}) AS __cov_raw", + projections.join(", "), + inner.join(", "), + from + ), + expression_ids: domain.expression_ids.clone(), + parameter_indices, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::SourceFileCoverage; + use crate::parser::{Analysis, TelemetryDomain}; + + #[test] + fn test_telemetry_query_empty_expression_ids() { + let analysis = Analysis { + domains: vec![TelemetryDomain { + expression_ids: vec![], + from_sql: "users".to_string(), + with_sql: "".to_string(), + }], + coverage: SourceFileCoverage { + format: "sql-cov/v1".to_string(), + file_path: "test.sql".to_string(), + dialect: "sqlite".to_string(), + raw_source: "".to_string(), + statements: vec![], + metrics: vec![], + unsupported: vec![], + }, + statement_sql: vec![], + }; + let queries = telemetry_queries(&analysis); + assert!(queries.is_empty()); + } + + #[test] + fn test_telemetry_query_empty_from_sql() { + let mut metrics = vec![]; + metrics.push(crate::model::CoverageMetric { + span: crate::model::ExpressionSpan { + id: 0, + start_offset: 0, + end_offset: 1, + start_line: 1, + start_column: 1, + end_line: 1, + end_column: 2, + raw_expression: "1".to_string(), + normalized_expression: "1".to_string(), + context: "".to_string(), + nullable: false, + parameter_indices: vec![], + }, + measurable: true, + hit_true_count: 0, + hit_false_count: 0, + hit_unknown_count: 0, + }); + + let analysis = Analysis { + domains: vec![TelemetryDomain { + expression_ids: vec![0], + from_sql: "".to_string(), + with_sql: "".to_string(), + }], + coverage: SourceFileCoverage { + format: "sql-cov/v1".to_string(), + file_path: "test.sql".to_string(), + dialect: "sqlite".to_string(), + raw_source: "".to_string(), + statements: vec![], + metrics, + unsupported: vec![], + }, + statement_sql: vec![], + }; + let queries = telemetry_queries(&analysis); + assert_eq!(queries.len(), 1); + assert_eq!(queries[0].sql, "SELECT COUNT(CASE WHEN __cov_0 IS TRUE THEN 1 END) AS __cov_0_true, COUNT(CASE WHEN __cov_0 IS FALSE THEN 1 END) AS __cov_0_false, COUNT(CASE WHEN __cov_0 IS NULL THEN 1 END) AS __cov_0_unknown FROM (SELECT (1) AS __cov_0) AS __cov_raw"); + } +} diff --git a/gems/sql-cov/src/lib.rs b/gems/sql-cov/src/lib.rs new file mode 100644 index 000000000..289d63ba9 --- /dev/null +++ b/gems/sql-cov/src/lib.rs @@ -0,0 +1,20 @@ +pub mod driver; +pub mod hazard; +pub mod instrument; +pub mod model; +pub mod nullability; +pub mod parser; +pub mod reporter; +pub mod sarif; +pub mod schema; + +pub use driver::{ + cover_mysql, cover_postgres, cover_sqlite, execute_mysql_setup, execute_postgres_setup, + execute_sqlite_setup, +}; +pub use hazard::{ + analyze_hazards, analyze_hazards_with_looker, parse_lookml, HazardFinding, HazardKind, + HazardReport, LookerJoin, +}; +pub use model::{CoverageMetric, SourceFileCoverage, ThreeValuedLogicState}; +pub use parser::{analyze_sql, DialectName}; diff --git a/gems/sql-cov/src/main.rs b/gems/sql-cov/src/main.rs new file mode 100644 index 000000000..d98363bc2 --- /dev/null +++ b/gems/sql-cov/src/main.rs @@ -0,0 +1,380 @@ +use anyhow::{bail, Context, Result}; +use clap::{Parser, Subcommand}; +use sql_cov::driver::{mysql_pool, postgres_pool, sqlite_pool}; +use sql_cov::hazard::{analyze_hazards, analyze_hazards_with_looker, parse_lookml, LookerJoin}; +use sql_cov::reporter; +use sql_cov::sarif; +use sql_cov::schema::SchemaCatalog; +use sql_cov::{ + analyze_sql, cover_mysql, cover_postgres, cover_sqlite, execute_mysql_setup, + execute_postgres_setup, execute_sqlite_setup, DialectName, +}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +#[command(name = "sql-cov", about = "Expression-level three-valued SQL coverage")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Subcommand)] +enum Command { + Analyze { + #[arg(long)] + input: PathBuf, + #[arg(long, default_value = "sqlite")] + dialect: String, + #[arg(long, default_value = "json")] + format: String, + #[arg(long)] + output: Option, + }, + Run { + #[arg(long)] + input: PathBuf, + #[arg(long)] + setup: Option, + #[arg(long, default_value = "sqlite::memory:")] + database: String, + #[arg(long, default_value = "sqlite")] + dialect: String, + #[arg(long, default_value = "lcov")] + format: String, + #[arg(long)] + output: Option, + #[arg(long = "param")] + parameters: Vec, + }, + Hazards { + #[arg(long)] + input: PathBuf, + #[arg(long)] + setup: Option, + #[arg(long, default_value = "sqlite::memory:")] + database: String, + #[arg(long, default_value = "sqlite")] + dialect: String, + #[arg(long, default_value = "sarif")] + format: String, + #[arg(long)] + output: Option, + #[arg(long)] + sqlfluff: bool, + #[arg(long)] + sqlfluff_sarif: Option, + #[arg(long = "looker-hazards")] + looker_hazards: Option, + }, + GenerateCheck { + #[arg(long)] + input: PathBuf, + #[arg(long)] + setup: Option, + #[arg(long, default_value = "sqlite::memory:")] + database: String, + #[arg(long, default_value = "sqlite")] + dialect: String, + #[arg(long)] + id: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Command::Analyze { + input, + dialect, + format, + output, + } => { + let source = fs::read_to_string(&input)?; + let analysis = analyze_sql( + &input.to_string_lossy(), + &source, + DialectName::parse(&dialect)?, + None, + )?; + let rendered = render(&analysis.coverage, &format)?; + write_output(output, &rendered)?; + } + Command::Run { + input, + setup, + database, + dialect, + format, + output, + parameters, + } => { + let dialect = DialectName::parse(&dialect)?; + let source = fs::read_to_string(&input)?; + let setup = setup.map(fs::read_to_string).transpose()?; + let (_analysis, coverage) = match dialect { + DialectName::Sqlite => { + let pool = sqlite_pool(&database).await?; + if let Some(setup) = &setup { + execute_sqlite_setup(&pool, setup).await?; + } + let schema = SchemaCatalog::load_sqlite(&pool).await.ok(); + let analysis = analyze_sql(&input.to_string_lossy(), &source, dialect, schema.as_ref())?; + let coverage = cover_sqlite(&pool, &analysis, ¶meters).await?; + (analysis, coverage) + } + #[cfg(not(coverage))] + DialectName::Postgres => { + let pool = postgres_pool(&database).await?; + if let Some(setup) = &setup { + execute_postgres_setup(&pool, setup).await?; + } + let schema = SchemaCatalog::load_postgres(&pool).await.ok(); + let analysis = analyze_sql(&input.to_string_lossy(), &source, dialect, schema.as_ref())?; + let coverage = cover_postgres(&pool, &analysis, ¶meters).await?; + (analysis, coverage) + } + #[cfg(not(coverage))] + DialectName::Mysql => { + let pool = mysql_pool(&database).await?; + if let Some(setup) = &setup { + execute_mysql_setup(&pool, setup).await?; + } + let schema = SchemaCatalog::load_mysql(&pool).await.ok(); + let analysis = analyze_sql(&input.to_string_lossy(), &source, dialect, schema.as_ref())?; + let coverage = cover_mysql(&pool, &analysis, ¶meters).await?; + (analysis, coverage) + } + #[cfg(coverage)] + DialectName::Postgres | DialectName::Mysql => { + anyhow::bail!("Postgres and MySQL databases are not supported in coverage runs") + } + }; + let rendered = render(&coverage, &format)?; + write_output(output, &rendered)?; + } + Command::Hazards { + input, + setup, + database, + dialect, + format, + output, + sqlfluff, + sqlfluff_sarif, + looker_hazards, + } => { + let dialect_name = DialectName::parse(&dialect)?; + let setup = setup.map(fs::read_to_string).transpose()?; + let schema = match dialect_name { + DialectName::Sqlite => { + let pool = sqlite_pool(&database).await?; + if let Some(setup) = &setup { + execute_sqlite_setup(&pool, setup).await?; + } + SchemaCatalog::load_sqlite(&pool).await? + } + #[cfg(not(coverage))] + DialectName::Postgres => { + let pool = postgres_pool(&database).await?; + if let Some(setup) = &setup { + execute_postgres_setup(&pool, setup).await?; + } + SchemaCatalog::load_postgres(&pool).await? + } + #[cfg(not(coverage))] + DialectName::Mysql => { + let pool = mysql_pool(&database).await?; + if let Some(setup) = &setup { + execute_mysql_setup(&pool, setup).await?; + } + SchemaCatalog::load_mysql(&pool).await? + } + #[cfg(coverage)] + DialectName::Postgres | DialectName::Mysql => { + anyhow::bail!("Postgres and MySQL databases are not supported in coverage runs") + } + }; + if schema.tables.is_empty() { + bail!("hazard analysis requires a populated SQLite schema; pass --setup or --database"); + } + let source = fs::read_to_string(&input)?; + let mut looker_joins = Vec::new(); + if let Some(looker_path) = &looker_hazards { + let looker_content = fs::read_to_string(looker_path)?; + looker_joins = parse_lookml(&looker_content); + } + let report = analyze_hazards_with_looker(&input.to_string_lossy(), &source, dialect_name, &schema, &looker_joins)?; + + let mut sqlfluff_sarif_content = None; + if let Some(path) = sqlfluff_sarif { + sqlfluff_sarif_content = Some(fs::read_to_string(path)?); + } else if sqlfluff { + let sqlfluff_paths = vec![ + "sqlfluff", + "/home/yahn/.sqlfluff-venv/bin/sqlfluff" + ]; + let mut found_bin = None; + for p in sqlfluff_paths { + if std::process::Command::new(p) + .arg("--version") + .output() + .is_ok() + { + found_bin = Some(p); + break; + } + } + + if let Some(bin) = found_bin { + let output = std::process::Command::new(bin) + .args([ + "lint", + "--dialect", + match dialect_name { + DialectName::Sqlite => "sqlite", + DialectName::Postgres => "postgres", + DialectName::Mysql => "mysql", + }, + "--format", + "sarif", + &input.to_string_lossy() + ]) + .output(); + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + sqlfluff_sarif_content = Some(stdout.into_owned()); + } + Err(e) => { + eprintln!("Warning: failed to execute sqlfluff lint: {}", e); + } + } + } else { + eprintln!("Warning: sqlfluff binary not found in PATH or ~/.sqlfluff-venv/bin/"); + } + } + + let rendered = match format.as_str() { + "sarif" => sarif::hazard_sarif(&report, sqlfluff_sarif_content.as_deref())?, + "json" => sarif::hazard_json(&report)?, + other => bail!("unsupported hazard format {other:?}; use sarif or json"), + }; + write_output(output, &rendered)?; + } + Command::GenerateCheck { + input, + setup, + database, + dialect, + id, + } => { + let dialect_name = DialectName::parse(&dialect)?; + let setup = setup.map(fs::read_to_string).transpose()?; + let schema = match dialect_name { + DialectName::Sqlite => { + let pool = sqlite_pool(&database).await?; + if let Some(setup) = &setup { + execute_sqlite_setup(&pool, setup).await?; + } + SchemaCatalog::load_sqlite(&pool).await? + } + #[cfg(not(coverage))] + DialectName::Postgres => { + let pool = postgres_pool(&database).await?; + if let Some(setup) = &setup { + execute_postgres_setup(&pool, setup).await?; + } + SchemaCatalog::load_postgres(&pool).await? + } + #[cfg(not(coverage))] + DialectName::Mysql => { + let pool = mysql_pool(&database).await?; + if let Some(setup) = &setup { + execute_mysql_setup(&pool, setup).await?; + } + SchemaCatalog::load_mysql(&pool).await? + } + #[cfg(coverage)] + DialectName::Postgres | DialectName::Mysql => { + anyhow::bail!("Postgres and MySQL databases are not supported in coverage runs") + } + }; + let source = fs::read_to_string(&input)?; + let report = analyze_hazards(&input.to_string_lossy(), &source, dialect_name, &schema)?; + + let Some(finding) = report.findings.iter().find(|f| f.id == id) else { + bail!("no hazard finding found in {} with id {}", input.to_string_lossy(), id); + }; + + let mut matched_targets = Vec::new(); + for ev in &finding.evidence { + if ev.starts_with("schema declares ") && ev.ends_with(" nullable") { + let inner = &ev["schema declares ".len()..(ev.len() - " nullable".len())]; + if let Some(dot_idx) = inner.find('.') { + let table = &inner[..dot_idx]; + let col = &inner[dot_idx + 1..]; + matched_targets.push((table.to_string(), col.to_string())); + } + } else if ev.starts_with("outer join can synthesize NULL for ") { + let inner = &ev["outer join can synthesize NULL for ".len()..]; + if let Some(dot_idx) = inner.find('.') { + let table = &inner[..dot_idx]; + let col = &inner[dot_idx + 1..]; + matched_targets.push((table.to_string(), col.to_string())); + } + } + } + + if matched_targets.is_empty() { + println!("-- Finding ID: {}", finding.id); + println!("-- Kind: {:?}", finding.kind); + println!("-- Message: {}", finding.message); + println!("-- Recommendation: {}", finding.recommendation); + println!("-- No specific nullable table columns could be extracted from evidence: {:?}", finding.evidence); + return Ok(()); + } + + println!("-- Finding ID: {}", finding.id); + println!("-- Kind: {:?}", finding.kind); + println!("-- Message: {}", finding.message); + println!("-- Recommendation: {}", finding.recommendation); + println!(); + + for (table, col) in matched_targets { + println!("-- Checking nullable column {}.{}", table, col); + println!("SELECT COUNT(*) AS null_count FROM {} WHERE {} IS NULL;", table, col); + println!(); + println!("-- If the check returns 0, you can safely make the column NOT NULL:"); + println!("-- PostgreSQL:"); + println!("-- ALTER TABLE {} ALTER COLUMN {} SET NOT NULL;", table, col); + println!("-- MySQL / MariaDB:"); + println!("-- ALTER TABLE {} MODIFY {} NOT NULL;", table, col); + println!("-- SQLite:"); + println!("-- SQLite does not support altering column nullability directly."); + println!("-- To apply the constraint, recreate the table with `NOT NULL` on `{}`.", col); + println!(); + } + } + } + Ok(()) +} + +fn render(coverage: &sql_cov::SourceFileCoverage, format: &str) -> Result { + match format { + "json" => reporter::json(coverage), + "lcov" | "info" => Ok(reporter::lcov(coverage)), + "html" => Ok(reporter::html(coverage)), + other => bail!("unsupported output format {other:?}; use json, lcov, or html"), + } +} + +fn write_output(output: Option, rendered: &str) -> Result<()> { + if let Some(path) = output { + fs::write(&path, rendered).with_context(|| format!("write {}", path.display()))?; + } else { + print!("{rendered}"); + } + Ok(()) +} diff --git a/gems/sql-cov/src/model.rs b/gems/sql-cov/src/model.rs new file mode 100644 index 000000000..65e6bfd7f --- /dev/null +++ b/gems/sql-cov/src/model.rs @@ -0,0 +1,98 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ThreeValuedLogicState { + True, + False, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExpressionSpan { + pub id: usize, + pub start_offset: usize, + pub end_offset: usize, + pub start_line: usize, + pub start_column: usize, + pub end_line: usize, + pub end_column: usize, + pub raw_expression: String, + pub normalized_expression: String, + pub context: String, + pub nullable: bool, + /// Zero-based source parameter ordinals used by anonymous `?` placeholders. + pub parameter_indices: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoverageMetric { + pub span: ExpressionSpan, + /// Whether the execution driver can collect telemetry for this expression. + pub measurable: bool, + pub hit_true_count: u64, + pub hit_false_count: u64, + pub hit_unknown_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StatementCoverage { + pub id: usize, + pub start_line: usize, + pub end_line: usize, + pub hit_count: u64, + pub normalized_sql: String, +} + +impl CoverageMetric { + pub fn covered_branch_count(&self) -> usize { + usize::from(self.hit_true_count > 0) + + usize::from(self.hit_false_count > 0) + + usize::from(self.span.nullable && self.hit_unknown_count > 0) + } + + pub fn branch_count(&self) -> usize { + if self.span.nullable { + 3 + } else { + 2 + } + } + + pub fn is_fully_covered(&self) -> bool { + self.covered_branch_count() == self.branch_count() + } + + pub fn is_uncovered(&self) -> bool { + self.hit_true_count == 0 && self.hit_false_count == 0 && self.hit_unknown_count == 0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceFileCoverage { + pub format: String, + pub file_path: String, + pub dialect: String, + pub raw_source: String, + pub metrics: Vec, + pub statements: Vec, + pub unsupported: Vec, +} + +impl SourceFileCoverage { + pub fn covered_branches(&self) -> usize { + self.metrics + .iter() + .filter(|metric| metric.measurable) + .map(CoverageMetric::covered_branch_count) + .sum() + } + + pub fn total_branches(&self) -> usize { + self.metrics + .iter() + .filter(|metric| metric.measurable) + .map(CoverageMetric::branch_count) + .sum() + } +} diff --git a/gems/sql-cov/src/nullability.rs b/gems/sql-cov/src/nullability.rs new file mode 100644 index 000000000..78a27154b --- /dev/null +++ b/gems/sql-cov/src/nullability.rs @@ -0,0 +1,675 @@ +use crate::schema::{normalize_identifier, SchemaCatalog}; +use sqlparser::ast::{ + BinaryOperator, Expr, FunctionArg, FunctionArgExpr, FunctionArguments, JoinOperator, + Select, TableFactor, Value, Visit, Visitor, +}; +use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Nullability { + Never, + Nullable, + Unknown, +} + +#[derive(Debug, Clone)] +pub struct NullabilityEvidence { + pub state: Nullability, + pub reasons: Vec, +} + +impl NullabilityEvidence { + pub fn never() -> Self { + Self { + state: Nullability::Never, + reasons: Vec::new(), + } + } + + pub fn unknown(reason: impl Into) -> Self { + Self { + state: Nullability::Unknown, + reasons: vec![reason.into()], + } + } + + pub fn nullable(reason: impl Into) -> Self { + Self { + state: Nullability::Nullable, + reasons: vec![reason.into()], + } + } + + pub fn merge(mut self, other: Self) -> Self { + self.state = match (self.state, other.state) { + (Nullability::Nullable, _) | (_, Nullability::Nullable) => Nullability::Nullable, + (Nullability::Unknown, _) | (_, Nullability::Unknown) => Nullability::Unknown, + _ => Nullability::Never, + }; + self.reasons.extend(other.reasons); + self.reasons.sort(); + self.reasons.dedup(); + self + } +} + +#[derive(Debug, Clone)] +pub struct Resolver<'a> { + pub schema: &'a SchemaCatalog, + pub aliases: HashMap>, + pub outer_nullable_aliases: HashSet, + pub non_nullable_columns: HashSet<(Option, String)>, +} + +impl<'a> Resolver<'a> { + pub fn new(schema: &'a SchemaCatalog) -> Self { + Self { + schema, + aliases: HashMap::new(), + outer_nullable_aliases: HashSet::new(), + non_nullable_columns: HashSet::new(), + } + } +} + +pub fn resolver_for_select<'a>(select: &Select, schema: &'a SchemaCatalog) -> Resolver<'a> { + let mut resolver = Resolver::new(schema); + for table in &select.from { + register_factor(&table.relation, false, &mut resolver); + for join in &table.joins { + let right_is_optional = matches!( + join.join_operator, + JoinOperator::Left(_) | JoinOperator::LeftOuter(_) | JoinOperator::FullOuter(_) + ); + if matches!( + join.join_operator, + JoinOperator::Right(_) | JoinOperator::RightOuter(_) | JoinOperator::FullOuter(_) + ) { + resolver + .outer_nullable_aliases + .extend(resolver.aliases.keys().cloned()); + } + register_factor(&join.relation, right_is_optional, &mut resolver); + } + } + + if let Some(selection) = &select.selection { + populate_non_nullable_columns(selection, &mut resolver); + } + + resolver +} + +pub fn register_factor(factor: &TableFactor, outer_nullable: bool, resolver: &mut Resolver<'_>) { + match factor { + TableFactor::Table { name, alias, .. } => { + let table = name.to_string(); + let table = table.rsplit('.').next().unwrap_or(&table); + let key = alias + .as_ref() + .map(|alias| alias.name.value.as_str()) + .unwrap_or(table); + let key = normalize_identifier(key); + resolver + .aliases + .insert(key.clone(), Some(normalize_identifier(table))); + if outer_nullable { + resolver.outer_nullable_aliases.insert(key); + } + } + TableFactor::Derived { alias, .. } => { + if let Some(alias) = alias { + let key = normalize_identifier(&alias.name.value); + resolver.aliases.insert(key.clone(), None); + if outer_nullable { + resolver.outer_nullable_aliases.insert(key); + } + } + } + _ => {} + } +} + +pub fn nullability( + expr: &Expr, + resolver: &Resolver<'_>, + outer_join_context: bool, +) -> NullabilityEvidence { + match expr { + Expr::Identifier(identifier) => resolve_unqualified(&identifier.value, resolver), + Expr::CompoundIdentifier(parts) if parts.len() >= 2 => resolve_qualified( + &parts[parts.len() - 2].value, + &parts[parts.len() - 1].value, + resolver, + outer_join_context, + ), + Expr::Value(value) => match value.value { + Value::Null => NullabilityEvidence::nullable("the expression contains a NULL literal"), + Value::Placeholder(_) => NullabilityEvidence::unknown("a bound parameter may be NULL"), + _ => NullabilityEvidence::never(), + }, + Expr::Nested(value) | Expr::UnaryOp { expr: value, .. } => { + nullability(value, resolver, outer_join_context) + } + Expr::Function(function) if function.name.to_string().eq_ignore_ascii_case("coalesce") => { + coalesce_nullability(&function.args, resolver, outer_join_context) + } + Expr::BinaryOp { left, right, .. } => nullability(left, resolver, outer_join_context) + .merge(nullability(right, resolver, outer_join_context)), + Expr::Between { + expr: value, + low, + high, + .. + } => nullability(value, resolver, outer_join_context) + .merge(nullability(low, resolver, outer_join_context)) + .merge(nullability(high, resolver, outer_join_context)), + Expr::InList { + expr: value, list, .. + } => list.iter().fold( + nullability(value, resolver, outer_join_context), + |state, item| state.merge(nullability(item, resolver, outer_join_context)), + ), + Expr::IsNull(_) + | Expr::IsNotNull(_) + | Expr::IsTrue(_) + | Expr::IsNotTrue(_) + | Expr::IsFalse(_) + | Expr::IsNotFalse(_) + | Expr::IsUnknown(_) + | Expr::IsNotUnknown(_) + | Expr::IsDistinctFrom(_, _) + | Expr::IsNotDistinctFrom(_, _) => NullabilityEvidence::never(), + _ => { + let mut visitor = NullableColumnVisitor { + resolver, + outer_join_context, + result: NullabilityEvidence::never(), + }; + let _ = expr.visit(&mut visitor); + if visitor.result.state == Nullability::Never { + NullabilityEvidence::unknown(format!( + "nullability of expression `{expr}` is not modeled" + )) + } else { + visitor.result + } + } + } +} + +fn coalesce_nullability( + arguments: &FunctionArguments, + resolver: &Resolver<'_>, + outer_join_context: bool, +) -> NullabilityEvidence { + let FunctionArguments::List(arguments) = arguments else { + return NullabilityEvidence::unknown("COALESCE arguments are unresolved"); + }; + let states = arguments + .args + .iter() + .filter_map(|argument| { + let argument = match argument { + FunctionArg::Named { arg, .. } + | FunctionArg::ExprNamed { arg, .. } + | FunctionArg::Unnamed(arg) => arg, + }; + match argument { + FunctionArgExpr::Expr(expr) => { + Some(nullability(expr, resolver, outer_join_context)) + } + _ => None, + } + }) + .collect::>(); + if states.iter().any(|state| state.state == Nullability::Never) { + return NullabilityEvidence::never(); + } + if states.is_empty() + || states + .iter() + .any(|state| state.state == Nullability::Unknown) + { + return NullabilityEvidence::unknown( + "COALESCE has an argument with unresolved nullability", + ); + } + states + .into_iter() + .fold(NullabilityEvidence::never(), NullabilityEvidence::merge) +} + +struct NullableColumnVisitor<'a> { + resolver: &'a Resolver<'a>, + outer_join_context: bool, + result: NullabilityEvidence, +} + +impl Visitor for NullableColumnVisitor<'_> { + type Break = (); + + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + let evidence = match expr { + Expr::Identifier(identifier) => { + Some(resolve_unqualified(&identifier.value, self.resolver)) + } + Expr::CompoundIdentifier(parts) if parts.len() >= 2 => Some(resolve_qualified( + &parts[parts.len() - 2].value, + &parts[parts.len() - 1].value, + self.resolver, + self.outer_join_context, + )), + Expr::Value(value) if matches!(value.value, Value::Null) => Some( + NullabilityEvidence::nullable("the expression contains a NULL literal"), + ), + _ => None, + }; + if let Some(evidence) = evidence { + self.result = self.result.clone().merge(evidence); + } + ControlFlow::Continue(()) + } +} + +pub fn resolve_qualified( + alias: &str, + column: &str, + resolver: &Resolver<'_>, + outer_join_context: bool, +) -> NullabilityEvidence { + let alias = normalize_identifier(alias); + let col_name = normalize_identifier(column); + if outer_join_context && resolver.outer_nullable_aliases.contains(&alias) { + return NullabilityEvidence::nullable(format!( + "outer join can synthesize NULL for {alias}.{column}" + )); + } + if resolver.non_nullable_columns.contains(&(Some(alias.clone()), col_name.clone())) + || resolver.non_nullable_columns.contains(&(None, col_name.clone())) + { + return NullabilityEvidence::never(); + } + let Some(table) = resolver.aliases.get(&alias) else { + return NullabilityEvidence::unknown(format!("schema alias `{alias}` is unresolved")); + }; + let Some(table) = table else { + return NullabilityEvidence::unknown(format!( + "derived-table column {alias}.{column} has unresolved nullability" + )); + }; + let Some(schema) = resolver.schema.column(table, column) else { + return NullabilityEvidence::unknown(format!( + "column {table}.{column} is absent from the loaded schema" + )); + }; + if schema.nullable { + NullabilityEvidence::nullable(format!("schema declares {table}.{} nullable", schema.name)) + } else { + NullabilityEvidence::never() + } +} + +pub fn resolve_unqualified(column: &str, resolver: &Resolver<'_>) -> NullabilityEvidence { + let col_name = normalize_identifier(column); + let matches = resolver + .aliases + .iter() + .filter_map(|(alias, table)| { + let table = table.as_ref()?; + resolver + .schema + .column(table, column) + .map(|schema| (alias, table, schema)) + }) + .collect::>(); + if matches.is_empty() { + return NullabilityEvidence::unknown(format!( + "unqualified column `{column}` is absent from the loaded schema" + )); + } + if matches + .iter() + .any(|(alias, _, _)| resolver.outer_nullable_aliases.contains(*alias)) + { + return NullabilityEvidence::nullable(format!( + "outer join can synthesize NULL for unqualified column {column}" + )); + } + if resolver.non_nullable_columns.contains(&(None, col_name.clone())) { + return NullabilityEvidence::never(); + } + if matches.iter().any(|(alias, _, _)| { + resolver.non_nullable_columns.contains(&(Some((*alias).clone()), col_name.clone())) + }) { + return NullabilityEvidence::never(); + } + if let Some((_, table, schema)) = matches.iter().find(|(_, _, schema)| schema.nullable) { + return NullabilityEvidence::nullable(format!( + "schema declares {table}.{} nullable", + schema.name + )); + } + NullabilityEvidence::never() +} + +pub fn explicitly_null_accepted_aliases(expr: &Expr) -> HashSet { + struct NullPolicyVisitor { + aliases: HashSet, + } + impl Visitor for NullPolicyVisitor { + type Break = (); + + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + if let Expr::IsNull(value) = expr { + self.aliases.extend(referenced_aliases(value)); + } + ControlFlow::Continue(()) + } + } + let mut visitor = NullPolicyVisitor { + aliases: HashSet::new(), + }; + let _ = expr.visit(&mut visitor); + visitor.aliases +} + +pub fn referenced_aliases(expr: &Expr) -> HashSet { + struct AliasVisitor { + aliases: HashSet, + } + impl Visitor for AliasVisitor { + type Break = (); + + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + if let Expr::CompoundIdentifier(parts) = expr { + if parts.len() >= 2 { + self.aliases + .insert(normalize_identifier(&parts[parts.len() - 2].value)); + } + } + ControlFlow::Continue(()) + } + } + let mut visitor = AliasVisitor { + aliases: HashSet::new(), + }; + let _ = expr.visit(&mut visitor); + visitor.aliases +} + +pub fn is_null_rejecting_predicate(expr: &Expr) -> bool { + matches!( + expr, + Expr::BinaryOp { + op: BinaryOperator::Eq + | BinaryOperator::NotEq + | BinaryOperator::Gt + | BinaryOperator::GtEq + | BinaryOperator::Lt + | BinaryOperator::LtEq, + .. + } | Expr::InList { .. } + | Expr::InSubquery { .. } + | Expr::InUnnest { .. } + | Expr::Between { .. } + | Expr::Like { .. } + | Expr::ILike { .. } + | Expr::SimilarTo { .. } + ) +} + +pub fn join_expr(operator: &JoinOperator) -> Option<&Expr> { + use sqlparser::ast::JoinConstraint; + let constraint = match operator { + JoinOperator::Join(value) + | JoinOperator::Inner(value) + | JoinOperator::Left(value) + | JoinOperator::LeftOuter(value) + | JoinOperator::Right(value) + | JoinOperator::RightOuter(value) + | JoinOperator::FullOuter(value) + | JoinOperator::CrossJoin(value) + | JoinOperator::Semi(value) + | JoinOperator::LeftSemi(value) + | JoinOperator::RightSemi(value) + | JoinOperator::Anti(value) + | JoinOperator::LeftAnti(value) + | JoinOperator::RightAnti(value) + | JoinOperator::StraightJoin(value) => value, + _ => return None, + }; + match constraint { + JoinConstraint::On(expr) => Some(expr), + _ => None, + } +} + +pub fn populate_non_nullable_columns(where_expr: &Expr, resolver: &mut Resolver<'_>) { + let mut conjuncts = Vec::new(); + find_conjuncts(where_expr, &mut conjuncts); + + for expr in conjuncts { + if let Expr::IsNotNull(inner_expr) = expr { + let cols = collect_column_references(inner_expr); + for (opt_alias, col_name) in cols { + resolver.non_nullable_columns.insert((opt_alias, col_name)); + } + } + } +} + +fn find_conjuncts<'a>(expr: &'a Expr, conjuncts: &mut Vec<&'a Expr>) { + match expr { + Expr::BinaryOp { left, op: BinaryOperator::And, right } => { + find_conjuncts(left, conjuncts); + find_conjuncts(right, conjuncts); + } + Expr::Nested(e) => { + find_conjuncts(e, conjuncts); + } + _ => { + conjuncts.push(expr); + } + } +} + +fn collect_column_references(expr: &Expr) -> Vec<(Option, String)> { + let mut columns: Vec<(Option, String)> = Vec::new(); + struct ColumnVisitor { + columns: Vec<(Option, String)>, + } + impl Visitor for ColumnVisitor { + type Break = (); + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + match expr { + Expr::Identifier(ident) => { + self.columns.push((None, normalize_identifier(&ident.value))); + } + Expr::CompoundIdentifier(idents) => { + if idents.len() >= 2 { + let alias = normalize_identifier(&idents[idents.len() - 2].value); + let column = normalize_identifier(&idents[idents.len() - 1].value); + self.columns.push((Some(alias), column)); + } + } + _ => {} + } + ControlFlow::Continue(()) + } + } + let mut visitor = ColumnVisitor { columns: Vec::new() }; + let _ = expr.visit(&mut visitor); + visitor.columns +} + +#[cfg(test)] +mod tests { + use super::*; + use sqlparser::dialect::GenericDialect; + use sqlparser::parser::Parser; + use sqlparser::ast::{Expr, FunctionArg, FunctionArgExpr, Value}; + + #[test] + fn test_nullability_edge_cases() { + let dialect = GenericDialect; + let schema = SchemaCatalog::default(); + + // 1. Right join / Full outer join + let sql = "SELECT * FROM a RIGHT JOIN b ON a.id = b.id FULL OUTER JOIN c ON b.id = c.id"; + let statements = Parser::parse_sql(&dialect, sql).unwrap(); + let sqlparser::ast::Statement::Query(query) = &statements[0] else { panic!() }; + let sqlparser::ast::SetExpr::Select(select) = &*query.body else { panic!() }; + let resolver = resolver_for_select(select, &schema); + assert!(resolver.aliases.contains_key("a")); + + // 2. TableFactor::Derived and other factors + let sql = "SELECT * FROM (SELECT 1) AS sub"; + let statements = Parser::parse_sql(&dialect, sql).unwrap(); + let sqlparser::ast::Statement::Query(query2) = &statements[0] else { panic!() }; + let sqlparser::ast::SetExpr::Select(select2) = &*query2.body else { panic!() }; + let resolver = resolver_for_select(select2, &schema); + assert!(resolver.aliases.contains_key("sub")); + + // 2.1 TableFactor::Derived outer_nullable + let sql_join = "SELECT * FROM a LEFT JOIN (SELECT 1) AS sub ON a.id = sub.id"; + let statements_join = Parser::parse_sql(&dialect, sql_join).unwrap(); + let sqlparser::ast::Statement::Query(query_join) = &statements_join[0] else { panic!() }; + let sqlparser::ast::SetExpr::Select(select_join) = &*query_join.body else { panic!() }; + let resolver_join = resolver_for_select(select_join, &schema); + assert!(resolver_join.outer_nullable_aliases.contains("sub")); + + // 2.2 TableFactor fallback + let mut dummy_resolver = Resolver::new(&schema); + let factor = sqlparser::ast::TableFactor::TableFunction { + expr: Expr::Value(sqlparser::ast::ValueWithSpan { value: Value::Null, span: sqlparser::tokenizer::Span::empty() }), + alias: None, + }; + register_factor(&factor, false, &mut dummy_resolver); + + // 3. Between, Null literal nullability + let sql = "SELECT 1 BETWEEN NULL AND 10"; + let statements = Parser::parse_sql(&dialect, sql).unwrap(); + let sqlparser::ast::Statement::Query(query3) = &statements[0] else { panic!() }; + let sqlparser::ast::SetExpr::Select(select3) = &*query3.body else { panic!() }; + let resolver3 = Resolver { + schema: &schema, + aliases: HashMap::new(), + outer_nullable_aliases: HashSet::new(), + non_nullable_columns: HashSet::new(), + }; + let sqlparser::ast::SelectItem::UnnamedExpr(expr3) = &select3.projection[0] else { panic!() }; + let ev3 = nullability(expr3, &resolver3, false); + assert_eq!(ev3.state, Nullability::Nullable); + + // 3.1 NullableColumnVisitor Null literal + let mut schema_visitor = SchemaCatalog::default(); + schema_visitor.insert_column("users".to_string(), "age".to_string(), false, false); + let mut visitor_resolver = Resolver::new(&schema_visitor); + visitor_resolver.aliases.insert("users".to_string(), Some("users".to_string())); + let sql_lit = "SELECT age + NULL FROM users"; + let statements_lit = Parser::parse_sql(&dialect, sql_lit).unwrap(); + let sqlparser::ast::Statement::Query(query_lit) = &statements_lit[0] else { panic!() }; + let sqlparser::ast::SetExpr::Select(select_lit) = &*query_lit.body else { panic!() }; + let sqlparser::ast::SelectItem::UnnamedExpr(expr_lit) = &select_lit.projection[0] else { panic!() }; + let ev_lit = nullability(expr_lit, &visitor_resolver, false); + assert_eq!(ev_lit.state, Nullability::Nullable); + + // 4. Coalesce arguments + let sql = "SELECT COALESCE(NULL, 1), COALESCE(), COALESCE(bonus)" ; + let mut schema_with_bonus = SchemaCatalog::default(); + schema_with_bonus.insert_column("users".to_string(), "bonus".to_string(), true, false); + let mut aliases = HashMap::new(); + aliases.insert("users".to_string(), Some("users".to_string())); + let statements = Parser::parse_sql(&dialect, sql).unwrap(); + let sqlparser::ast::Statement::Query(query4) = &statements[0] else { panic!() }; + let sqlparser::ast::SetExpr::Select(select4) = &*query4.body else { panic!() }; + let resolver4 = Resolver { + schema: &schema_with_bonus, + aliases, + outer_nullable_aliases: HashSet::new(), + non_nullable_columns: HashSet::new(), + }; + // COALESCE(NULL, 1) -> Never nullable since 1 is never nullable + let sqlparser::ast::SelectItem::UnnamedExpr(Expr::Function(func1)) = &select4.projection[0] else { panic!() }; + let ev_c1 = coalesce_nullability(&func1.args, &resolver4, false); + assert_eq!(ev_c1.state, Nullability::Never); + + // COALESCE() empty -> Unknown + let sqlparser::ast::SelectItem::UnnamedExpr(Expr::Function(func2)) = &select4.projection[1] else { panic!() }; + let ev_c2 = coalesce_nullability(&func2.args, &resolver4, false); + assert_eq!(ev_c2.state, Nullability::Unknown); + + // COALESCE(bonus) -> Nullable since bonus is nullable + let sqlparser::ast::SelectItem::UnnamedExpr(Expr::Function(func3)) = &select4.projection[2] else { panic!() }; + let ev_c3 = coalesce_nullability(&func3.args, &resolver4, false); + assert_eq!(ev_c3.state, Nullability::Nullable); + + // 4.1 Coalesce non-List and non-Unnamed/named variants + let ev_unresolved = coalesce_nullability(&sqlparser::ast::FunctionArguments::None, &resolver4, false); + assert_eq!(ev_unresolved.state, Nullability::Unknown); + + // 5. resolve_qualified derived table and resolve_unqualified outer join nullable + let mut schema_with_cols = SchemaCatalog::default(); + schema_with_cols.insert_column("a".to_string(), "id".to_string(), false, true); + let mut aliases = HashMap::new(); + aliases.insert("sub".to_string(), None); // derived table + aliases.insert("a".to_string(), Some("a".to_string())); + let mut outer_nullable_aliases = HashSet::new(); + outer_nullable_aliases.insert("a".to_string()); + let resolver = Resolver { + schema: &schema_with_cols, + aliases, + outer_nullable_aliases, + non_nullable_columns: HashSet::new(), + }; + let ev1 = resolve_qualified("sub", "id", &resolver, false); + assert_eq!(ev1.state, Nullability::Unknown); + + let ev2 = resolve_unqualified("id", &resolver); + assert_eq!(ev2.state, Nullability::Nullable); + + // 6. JoinExpr match arms coverage + use sqlparser::ast::{JoinConstraint, JoinOperator, ValueWithSpan}; + let expr = Expr::Value(ValueWithSpan { value: Value::Null, span: sqlparser::tokenizer::Span::empty() }); + let operators = vec![ + JoinOperator::Inner(JoinConstraint::On(expr.clone())), + JoinOperator::Left(JoinConstraint::On(expr.clone())), + JoinOperator::LeftOuter(JoinConstraint::On(expr.clone())), + JoinOperator::Right(JoinConstraint::On(expr.clone())), + JoinOperator::RightOuter(JoinConstraint::On(expr.clone())), + JoinOperator::FullOuter(JoinConstraint::On(expr.clone())), + JoinOperator::CrossJoin(JoinConstraint::On(expr.clone())), + JoinOperator::Semi(JoinConstraint::On(expr.clone())), + JoinOperator::LeftSemi(JoinConstraint::On(expr.clone())), + JoinOperator::RightSemi(JoinConstraint::On(expr.clone())), + JoinOperator::Anti(JoinConstraint::On(expr.clone())), + JoinOperator::LeftAnti(JoinConstraint::On(expr.clone())), + JoinOperator::RightAnti(JoinConstraint::On(expr.clone())), + JoinOperator::StraightJoin(JoinConstraint::On(expr.clone())), + JoinOperator::Join(JoinConstraint::None), + JoinOperator::Inner(JoinConstraint::Using(vec![])), + ]; + for op in operators { + let _ = join_expr(&op); + } + + // 7. Predicate propagation test + let mut schema_with_line = SchemaCatalog::default(); + schema_with_line.insert_column("test_exposure_events".to_string(), "line".to_string(), true, false); + let sql = "SELECT t.line FROM test_exposure_events t WHERE t.line IS NOT NULL"; + let statements = Parser::parse_sql(&dialect, sql).unwrap(); + let sqlparser::ast::Statement::Query(query_prop) = &statements[0] else { panic!() }; + let sqlparser::ast::SetExpr::Select(select_prop) = &*query_prop.body else { panic!() }; + let resolver_prop = resolver_for_select(select_prop, &schema_with_line); + + // Assert that t.line is resolved to Never nullable because of the WHERE t.line IS NOT NULL clause + let t_line_expr = Expr::CompoundIdentifier(vec![ + sqlparser::ast::Ident::new("t"), + sqlparser::ast::Ident::new("line"), + ]); + let ev_prop = nullability(&t_line_expr, &resolver_prop, false); + assert_eq!(ev_prop.state, Nullability::Never); + } +} diff --git a/gems/sql-cov/src/parser.rs b/gems/sql-cov/src/parser.rs new file mode 100644 index 000000000..943741332 --- /dev/null +++ b/gems/sql-cov/src/parser.rs @@ -0,0 +1,837 @@ +use crate::model::{CoverageMetric, ExpressionSpan, SourceFileCoverage, StatementCoverage}; +use crate::nullability::{register_factor, Resolver}; +use crate::schema::SchemaCatalog; +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use sqlparser::ast::{ + BinaryOperator, Expr, JoinConstraint, JoinOperator, SetExpr, Spanned, Statement, Visit, Visitor, +}; +use sqlparser::dialect::{Dialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect}; +use sqlparser::parser::Parser; +use std::ops::ControlFlow; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DialectName { + Sqlite, + Postgres, + Mysql, +} + +impl DialectName { + pub fn parse(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "sqlite" | "sqlite3" => Ok(Self::Sqlite), + "postgres" | "postgresql" | "pg" => Ok(Self::Postgres), + "mysql" | "mariadb" | "maria" => Ok(Self::Mysql), + other => { + bail!("unsupported SQL dialect {other:?}; use sqlite, postgres, mysql, or mariadb") + } + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgres", + Self::Mysql => "mysql", + } + } +} + +#[derive(Debug, Clone)] +pub struct TelemetryDomain { + pub with_sql: String, + pub from_sql: String, + pub expression_ids: Vec, +} + +#[derive(Debug, Clone)] +pub struct Analysis { + pub coverage: SourceFileCoverage, + pub domains: Vec, + pub statement_sql: Vec, +} + +pub fn analyze_sql( + file_path: &str, + source: &str, + dialect: DialectName, + schema: Option<&SchemaCatalog>, +) -> Result { + let dialect_impl: Box = match dialect { + DialectName::Sqlite => Box::new(SQLiteDialect {}), + DialectName::Postgres => Box::new(PostgreSqlDialect {}), + DialectName::Mysql => Box::new(MySqlDialect {}), + }; + let statements = Parser::parse_sql(dialect_impl.as_ref(), source) + .with_context(|| format!("parse SQL in {file_path}"))?; + let mut metrics = Vec::new(); + let mut domains = Vec::new(); + let mut unsupported = Vec::new(); + let mut statement_coverage = Vec::new(); + let mut statement_sql = Vec::new(); + + let dummy_schema = SchemaCatalog::default(); + let schema_ref = schema.unwrap_or(&dummy_schema); + + for (statement_id, statement) in statements.iter().enumerate() { + let span = statement.span(); + statement_coverage.push(StatementCoverage { + id: statement_id, + start_line: span.start.line.max(1) as usize, + end_line: span.end.line.max(span.start.line).max(1) as usize, + hit_count: 0, + normalized_sql: statement.to_string(), + }); + statement_sql.push(statement.to_string()); + let Statement::Query(query) = statement else { + unsupported.push(format!( + "statement {} is parsed but not executable coverage input", + statement + )); + continue; + }; + + let resolver = Resolver::new(schema_ref); + traverse_query( + query, + &resolver, + schema, + source, + &mut metrics, + &mut domains, + &mut unsupported, + ); + } + + unsupported.sort(); + unsupported.dedup(); + Ok(Analysis { + coverage: SourceFileCoverage { + format: "sql-cov/v1".to_string(), + file_path: file_path.to_string(), + dialect: dialect.as_str().to_string(), + raw_source: source.to_string(), + metrics, + statements: statement_coverage, + unsupported, + }, + domains, + statement_sql, + }) +} + +fn traverse_query( + query: &sqlparser::ast::Query, + outer_resolver: &Resolver<'_>, + schema: Option<&SchemaCatalog>, + source: &str, + metrics: &mut Vec, + domains: &mut Vec, + unsupported: &mut Vec, +) { + let mut local_resolver = outer_resolver.clone(); + let with_clause = &query.with; + if let Some(with) = with_clause { + for cte in &with.cte_tables { + let name = crate::schema::normalize_identifier(&cte.alias.name.value); + local_resolver.aliases.insert(name, None); + traverse_query( + &cte.query, + outer_resolver, + schema, + source, + metrics, + domains, + unsupported, + ); + } + } + traverse_set_expr( + &query.body, + &local_resolver, + schema, + source, + metrics, + domains, + unsupported, + with_clause, + outer_resolver, + ); +} + +fn traverse_set_expr( + expr: &SetExpr, + resolver: &Resolver<'_>, + schema: Option<&SchemaCatalog>, + source: &str, + metrics: &mut Vec, + domains: &mut Vec, + unsupported: &mut Vec, + with_clause: &Option, + outer_resolver: &Resolver<'_>, +) { + match expr { + SetExpr::Select(select) => { + traverse_select( + select, + resolver, + schema, + source, + metrics, + domains, + unsupported, + with_clause, + outer_resolver, + ); + } + SetExpr::Query(query) => { + traverse_query( + query, + resolver, + schema, + source, + metrics, + domains, + unsupported, + ); + } + SetExpr::SetOperation { left, right, .. } => { + traverse_set_expr( + left, + resolver, + schema, + source, + metrics, + domains, + unsupported, + &None, + outer_resolver, + ); + traverse_set_expr( + right, + resolver, + schema, + source, + metrics, + domains, + unsupported, + &None, + outer_resolver, + ); + } + SetExpr::Values(_) + | SetExpr::Insert(_) + | SetExpr::Table(_) + | SetExpr::Update(_) + | SetExpr::Delete(_) + | SetExpr::Merge(_) => {} + } +} + +fn traverse_select( + select: &sqlparser::ast::Select, + outer_resolver: &Resolver<'_>, + schema: Option<&SchemaCatalog>, + source: &str, + metrics: &mut Vec, + domains: &mut Vec, + unsupported: &mut Vec, + with_clause: &Option, + parent_resolver: &Resolver<'_>, +) { + let mut resolver = outer_resolver.clone(); + for table in &select.from { + register_factor(&table.relation, false, &mut resolver); + for join in &table.joins { + let right_is_optional = matches!( + join.join_operator, + JoinOperator::Left(_) | JoinOperator::LeftOuter(_) | JoinOperator::FullOuter(_) + ); + if matches!( + join.join_operator, + JoinOperator::Right(_) | JoinOperator::RightOuter(_) | JoinOperator::FullOuter(_) + ) { + resolver + .outer_nullable_aliases + .extend(resolver.aliases.keys().cloned()); + } + register_factor(&join.relation, right_is_optional, &mut resolver); + } + } + + let with_sql = with_clause + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(); + let from_sql = select + .from + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + + if let Some(selection) = &select.selection { + let ids = collect_exprs_with_resolver( + selection, + "where", + true, + source, + metrics, + schema, + &resolver, + parent_resolver, + ); + if !ids.is_empty() { + domains.push(TelemetryDomain { + with_sql: with_sql.clone(), + from_sql: from_sql.clone(), + expression_ids: ids, + }); + } + } + + if let Some(having) = &select.having { + let _ids = collect_exprs_with_resolver( + having, + "having", + false, + source, + metrics, + schema, + &resolver, + parent_resolver, + ); + unsupported.push( + "HAVING expressions are mapped but not executed in the initial telemetry driver" + .to_string(), + ); + } + + for table in &select.from { + let mut left_side_sql = table.relation.to_string(); + for join in &table.joins { + let right_side_sql = join.relation.to_string(); + if let Some(join_expr) = join_expression(&join.join_operator) { + let ids = collect_exprs_with_resolver( + join_expr, + "join", + true, + source, + metrics, + schema, + &resolver, + parent_resolver, + ); + if !ids.is_empty() { + let cross_join_from = format!("({}) CROSS JOIN {}", left_side_sql, right_side_sql); + domains.push(TelemetryDomain { + with_sql: with_sql.clone(), + from_sql: cross_join_from, + expression_ids: ids, + }); + } + } + left_side_sql = format!("{} {}", left_side_sql, join); + } + } + + // Recurse into subqueries inside all clauses of this SELECT query block + let mut collector = SubqueryCollector::default(); + if let Some(selection) = &select.selection { + let _ = selection.visit(&mut collector); + } + if let Some(having) = &select.having { + let _ = having.visit(&mut collector); + } + for item in &select.projection { + let _ = item.visit(&mut collector); + } + for table in &select.from { + let _ = table.relation.visit(&mut collector); + for join in &table.joins { + let _ = join.relation.visit(&mut collector); + if let Some(expr) = join_expression(&join.join_operator) { + let _ = expr.visit(&mut collector); + } + } + } + + for subquery in collector.subqueries { + traverse_query( + &subquery, + &resolver, + schema, + source, + metrics, + domains, + unsupported, + ); + } +} + +#[derive(Default)] +struct SubqueryCollector { + subqueries: Vec, +} + +impl Visitor for SubqueryCollector { + type Break = (); + + fn pre_visit_query(&mut self, query: &sqlparser::ast::Query) -> ControlFlow { + self.subqueries.push(query.clone()); + ControlFlow::Continue(()) + } +} + +struct ExpressionRecord { + span: ExpressionSpan, + expr: Expr, +} + +fn collect_exprs_with_resolver( + root: &Expr, + context: &str, + measurable: bool, + source: &str, + metrics: &mut Vec, + schema: Option<&SchemaCatalog>, + resolver: &Resolver<'_>, + outer_resolver: &Resolver<'_>, +) -> Vec { + let mut rows = Vec::new(); + find_coverage_expressions(root, context, source, &mut rows); + let mut ids = Vec::new(); + for mut row in rows { + let is_correlated = is_correlated_expression(&row.expr, resolver, outer_resolver); + + let row_measurable = measurable && !is_correlated; + + if let Some(_schema) = schema { + let resolver_ref = resolver; + let evidence = crate::nullability::nullability(&row.expr, resolver_ref, false); + row.span.nullable = match evidence.state { + crate::nullability::Nullability::Never => false, + _ => true, + }; + } + + if let Some(existing) = metrics.iter_mut().find(|metric| { + metric.span.start_offset == row.span.start_offset + && metric.span.end_offset == row.span.end_offset + && metric.span.context == row.span.context + }) { + if row_measurable { + existing.measurable = true; + ids.push(existing.span.id); + } + continue; + } + row.span.id = metrics.len(); + if row_measurable { + ids.push(row.span.id); + } + metrics.push(CoverageMetric { + span: row.span, + measurable: row_measurable, + hit_true_count: 0, + hit_false_count: 0, + hit_unknown_count: 0, + }); + } + ids +} + +fn find_coverage_expressions( + expr: &Expr, + context: &str, + source: &str, + rows: &mut Vec, +) { + if is_coverage_expression(expr) { + if let Some(span) = expression_span(expr, context, source) { + rows.push(ExpressionRecord { + span, + expr: expr.clone(), + }); + } + } + + match expr { + Expr::Nested(inner) | Expr::UnaryOp { expr: inner, .. } => { + find_coverage_expressions(inner, context, source, rows); + } + Expr::BinaryOp { left, right, .. } => { + find_coverage_expressions(left, context, source, rows); + find_coverage_expressions(right, context, source, rows); + } + Expr::Between { expr: val, low, high, .. } => { + find_coverage_expressions(val, context, source, rows); + find_coverage_expressions(low, context, source, rows); + find_coverage_expressions(high, context, source, rows); + } + Expr::InList { expr: val, list, .. } => { + find_coverage_expressions(val, context, source, rows); + for item in list { + find_coverage_expressions(item, context, source, rows); + } + } + Expr::InSubquery { expr: val, .. } => { + find_coverage_expressions(val, context, source, rows); + } + Expr::IsNull(inner) + | Expr::IsNotNull(inner) + | Expr::IsTrue(inner) + | Expr::IsNotTrue(inner) + | Expr::IsFalse(inner) + | Expr::IsNotFalse(inner) + | Expr::IsUnknown(inner) + | Expr::IsNotUnknown(inner) => { + find_coverage_expressions(inner, context, source, rows); + } + Expr::IsDistinctFrom(left, right) + | Expr::IsNotDistinctFrom(left, right) => { + find_coverage_expressions(left, context, source, rows); + find_coverage_expressions(right, context, source, rows); + } + Expr::Function(function) => { + if let sqlparser::ast::FunctionArguments::List(args) = &function.args { + for arg in &args.args { + match arg { + sqlparser::ast::FunctionArg::Unnamed(sqlparser::ast::FunctionArgExpr::Expr(e)) => { + find_coverage_expressions(e, context, source, rows); + } + sqlparser::ast::FunctionArg::Named { arg: sqlparser::ast::FunctionArgExpr::Expr(e), .. } + | sqlparser::ast::FunctionArg::ExprNamed { arg: sqlparser::ast::FunctionArgExpr::Expr(e), .. } => { + find_coverage_expressions(e, context, source, rows); + } + _ => {} + } + } + } + } + Expr::Case { operand, conditions, else_result, .. } => { + if let Some(op) = operand { + find_coverage_expressions(op, context, source, rows); + } + for cond in conditions { + find_coverage_expressions(&cond.condition, context, source, rows); + find_coverage_expressions(&cond.result, context, source, rows); + } + if let Some(el) = else_result { + find_coverage_expressions(el, context, source, rows); + } + } + _ => {} + } +} + +fn is_correlated_expression( + expr: &Expr, + combined_resolver: &Resolver<'_>, + outer_resolver: &Resolver<'_>, +) -> bool { + let refs = crate::nullability::referenced_aliases(expr); + for r in refs { + if outer_resolver.aliases.contains_key(&r) && !is_local_alias(&r, combined_resolver, outer_resolver) { + return true; + } + } + + struct UnqualifiedVisitor<'a> { + combined_resolver: &'a Resolver<'a>, + outer_resolver: &'a Resolver<'a>, + correlated: bool, + } + impl Visitor for UnqualifiedVisitor<'_> { + type Break = (); + fn pre_visit_expr(&mut self, expr: &Expr) -> ControlFlow { + if let Expr::Identifier(id) = expr { + let name = &id.value; + let in_local = self.combined_resolver.aliases.iter() + .filter(|(alias, _)| !self.outer_resolver.aliases.contains_key(*alias)) + .any(|(_, table)| { + table.as_ref().map_or(false, |t| self.combined_resolver.schema.column(t, name).is_some()) + }); + if !in_local { + let in_outer = self.outer_resolver.aliases.iter().any(|(_, table)| { + table.as_ref().map_or(false, |t| self.outer_resolver.schema.column(t, name).is_some()) + }); + if in_outer { + self.correlated = true; + return ControlFlow::Break(()); + } + } + } + ControlFlow::Continue(()) + } + } + let mut visitor = UnqualifiedVisitor { + combined_resolver, + outer_resolver, + correlated: false, + }; + let _ = expr.visit(&mut visitor); + visitor.correlated +} + +fn is_local_alias(alias: &str, combined: &Resolver<'_>, outer: &Resolver<'_>) -> bool { + combined.aliases.contains_key(alias) && !outer.aliases.contains_key(alias) +} + +fn is_coverage_expression(expr: &Expr) -> bool { + match expr { + Expr::BinaryOp { op, .. } => matches!( + op, + BinaryOperator::Gt + | BinaryOperator::Lt + | BinaryOperator::GtEq + | BinaryOperator::LtEq + | BinaryOperator::Spaceship + | BinaryOperator::Eq + | BinaryOperator::NotEq + | BinaryOperator::And + | BinaryOperator::Or + | BinaryOperator::Xor + ), + Expr::InList { .. } + | Expr::InSubquery { .. } + | Expr::Between { .. } + | Expr::IsNull(_) + | Expr::IsNotNull(_) + | Expr::IsTrue(_) + | Expr::IsFalse(_) + | Expr::IsUnknown(_) + | Expr::IsNotUnknown(_) + | Expr::IsDistinctFrom(_, _) + | Expr::IsNotDistinctFrom(_, _) + | Expr::Like { .. } + | Expr::ILike { .. } + | Expr::SimilarTo { .. } => true, + _ => false, + } +} + +fn expression_span(expr: &Expr, context: &str, source: &str) -> Option { + let parser_span = expr.span(); + if parser_span.start.line == 0 || parser_span.end.line == 0 { + return None; + } + let start_offset = location_offset( + source, + parser_span.start.line as usize, + parser_span.start.column as usize, + )?; + let end_offset = location_offset( + source, + parser_span.end.line as usize, + parser_span.end.column as usize, + )?; + let raw = source.get(start_offset..end_offset)?.to_string(); + Some(ExpressionSpan { + id: 0, + start_offset, + end_offset, + start_line: parser_span.start.line as usize, + start_column: parser_span.start.column as usize, + end_line: parser_span.end.line as usize, + end_column: parser_span.end.column as usize, + raw_expression: raw, + normalized_expression: expr.to_string(), + context: context.to_string(), + nullable: !matches!( + expr, + Expr::IsNull(_) + | Expr::IsNotNull(_) + | Expr::IsTrue(_) + | Expr::IsFalse(_) + | Expr::IsUnknown(_) + | Expr::IsNotUnknown(_) + | Expr::IsDistinctFrom(_, _) + | Expr::IsNotDistinctFrom(_, _) + ), + parameter_indices: anonymous_parameter_indices(source, start_offset, end_offset), + }) +} + +fn anonymous_parameter_indices(source: &str, start: usize, end: usize) -> Vec { + let mut indices = Vec::new(); + let mut ordinal = 0; + let mut quote = None; + let bytes = source.as_bytes(); + let mut offset = 0; + while offset < bytes.len() { + let byte = bytes[offset]; + if let Some(active) = quote { + if byte == active { + if bytes.get(offset + 1) == Some(&active) { + offset += 2; + continue; + } + quote = None; + } + } else if matches!(byte, b'\'' | b'"' | b'`') { + quote = Some(byte); + } else if byte == b'?' { + if offset >= start && offset < end { + indices.push(ordinal); + } + ordinal += 1; + } + offset += 1; + } + indices +} + +fn location_offset(source: &str, line: usize, column: usize) -> Option { + if line == 0 || column == 0 { + return None; + } + let line_start = source + .split_inclusive('\n') + .take(line.saturating_sub(1)) + .map(str::len) + .sum::(); + let line_text = source.get(line_start..)?.split('\n').next()?; + let column_offset = line_text + .char_indices() + .nth(column.saturating_sub(1)) + .map(|(offset, _)| offset) + .or_else(|| (column == line_text.chars().count() + 1).then_some(line_text.len()))?; + Some(line_start + column_offset) +} + +fn join_expression(operator: &JoinOperator) -> Option<&Expr> { + let constraint = match operator { + JoinOperator::Join(value) + | JoinOperator::Inner(value) + | JoinOperator::Left(value) + | JoinOperator::LeftOuter(value) + | JoinOperator::Right(value) + | JoinOperator::RightOuter(value) + | JoinOperator::FullOuter(value) + | JoinOperator::CrossJoin(value) + | JoinOperator::Semi(value) + | JoinOperator::LeftSemi(value) + | JoinOperator::RightSemi(value) + | JoinOperator::Anti(value) + | JoinOperator::LeftAnti(value) + | JoinOperator::RightAnti(value) + | JoinOperator::StraightJoin(value) => value, + _ => return None, + }; + match constraint { + JoinConstraint::On(expr) => Some(expr), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parser_edge_cases() { + let schema = SchemaCatalog::default(); + + // 1. CTE traversal + let sql = "WITH cte AS (SELECT 1) SELECT * FROM cte"; + let analysis = analyze_sql("test.sql", sql, DialectName::Sqlite, Some(&schema)).unwrap(); + assert!(analysis.domains.is_empty()); + + // 2. HAVING expression + let sql = "SELECT age FROM users GROUP BY age HAVING age > 18"; + let analysis = analyze_sql("test.sql", sql, DialectName::Sqlite, Some(&schema)).unwrap(); + assert!(analysis.coverage.unsupported.iter().any(|u| u.contains("HAVING"))); + + // 3. Correlated subquery with unqualified outer columns + let mut schema_with_cols = SchemaCatalog::default(); + schema_with_cols.insert_column("users".to_string(), "age".to_string(), false, false); + let sql = "SELECT * FROM users WHERE age > (SELECT MIN(age) FROM users WHERE age > 18)"; + let analysis = analyze_sql("test.sql", sql, DialectName::Sqlite, Some(&schema_with_cols)).unwrap(); + assert_eq!(analysis.coverage.metrics.len(), 2); + + // 4. Parenthesized query in set expression & escaped quotes + let sql_set = "SELECT 1 UNION (SELECT 'hello''world' WHERE age > 20)"; + let analysis_set = analyze_sql("test.sql", sql_set, DialectName::Sqlite, Some(&schema_with_cols)).unwrap(); + println!("SET METRICS: {:#?}", analysis_set.coverage.metrics); + assert_eq!(analysis_set.coverage.metrics.len(), 1); + + // 5. Named arguments & wildcard function args & JoinOperator coverage + let sql_func = "SELECT 1 WHERE my_func(x => COUNT(*)) = 1 AND my_func(COUNT(*)) = 1"; + let dialect_pg = sqlparser::dialect::PostgreSqlDialect {}; + let statements_func = sqlparser::parser::Parser::parse_sql(&dialect_pg, sql_func).unwrap(); + let sqlparser::ast::Statement::Query(query_func) = &statements_func[0] else { panic!() }; + let sqlparser::ast::SetExpr::Select(select_func) = &*query_func.body else { panic!() }; + let selection_func = select_func.selection.as_ref().unwrap(); + let mut metrics_func = Vec::new(); + find_coverage_expressions(selection_func, "where", sql_func, &mut metrics_func); + assert!(!metrics_func.is_empty()); + + // 6. Direct collect_exprs_with_resolver calls for deduplication + let mut metrics = Vec::new(); + let resolver = Resolver::new(&schema); + let dialect = sqlparser::dialect::GenericDialect; + let statements = sqlparser::parser::Parser::parse_sql(&dialect, "SELECT 1 IS NULL").unwrap(); + let sqlparser::ast::Statement::Query(query_dedup) = &statements[0] else { panic!() }; + let sqlparser::ast::SetExpr::Select(select_dedup) = &*query_dedup.body else { panic!() }; + let sqlparser::ast::SelectItem::UnnamedExpr(expr) = &select_dedup.projection[0] else { panic!() }; + + let source_str = "1 IS NULL"; + let ids1 = collect_exprs_with_resolver( + &expr, + "where", + true, + source_str, + &mut metrics, + Some(&schema), + &resolver, + &resolver, + ); + assert_eq!(ids1.len(), 1); + + // Call it again to hit deduplication path + let ids2 = collect_exprs_with_resolver( + &expr, + "where", + true, + source_str, + &mut metrics, + Some(&schema), + &resolver, + &resolver, + ); + assert_eq!(ids2.len(), 1); + + // 7. location_offset and expression_span edge cases + assert!(location_offset("test", 0, 0).is_none()); + assert!(location_offset("test", 5, 5).is_none()); + assert!(expression_span(&expr, "where", "").is_none()); + + // 8. Join operators coverage + use sqlparser::ast::{JoinConstraint, JoinOperator, ValueWithSpan, Value}; + let op_expr = sqlparser::ast::Expr::Value(ValueWithSpan { value: Value::Null, span: sqlparser::tokenizer::Span::empty() }); + let operators = vec![ + JoinOperator::Join(JoinConstraint::On(op_expr.clone())), + JoinOperator::Inner(JoinConstraint::On(op_expr.clone())), + JoinOperator::Left(JoinConstraint::On(op_expr.clone())), + JoinOperator::LeftOuter(JoinConstraint::On(op_expr.clone())), + JoinOperator::Right(JoinConstraint::On(op_expr.clone())), + JoinOperator::RightOuter(JoinConstraint::On(op_expr.clone())), + JoinOperator::FullOuter(JoinConstraint::On(op_expr.clone())), + JoinOperator::CrossJoin(JoinConstraint::On(op_expr.clone())), + JoinOperator::Semi(JoinConstraint::On(op_expr.clone())), + JoinOperator::LeftSemi(JoinConstraint::On(op_expr.clone())), + JoinOperator::RightSemi(JoinConstraint::On(op_expr.clone())), + JoinOperator::Anti(JoinConstraint::On(op_expr.clone())), + JoinOperator::LeftAnti(JoinConstraint::On(op_expr.clone())), + JoinOperator::RightAnti(JoinConstraint::On(op_expr.clone())), + JoinOperator::StraightJoin(JoinConstraint::On(op_expr.clone())), + JoinOperator::Join(JoinConstraint::None), + ]; + for op in operators { + let _ = join_expression(&op); + } + } +} diff --git a/gems/sql-cov/src/reporter.rs b/gems/sql-cov/src/reporter.rs new file mode 100644 index 000000000..63bf74b4a --- /dev/null +++ b/gems/sql-cov/src/reporter.rs @@ -0,0 +1,200 @@ +use crate::model::SourceFileCoverage; +use anyhow::Result; +use std::collections::BTreeMap; + +pub fn json(coverage: &SourceFileCoverage) -> Result { + Ok(serde_json::to_string_pretty(coverage)?) +} + +pub fn lcov(coverage: &SourceFileCoverage) -> String { + let mut out = format!("TN:sql-cov\nSF:{}\n", coverage.file_path); + let mut lines = BTreeMap::::new(); + for statement in &coverage.statements { + for line in statement.start_line..=statement.end_line { + lines + .entry(line) + .and_modify(|hits| *hits = (*hits).max(statement.hit_count)) + .or_insert(statement.hit_count); + } + } + let mut branch_found = 0; + let mut branch_hit = 0; + for metric in coverage.metrics.iter().filter(|metric| metric.measurable) { + let states = [ + metric.hit_true_count, + metric.hit_false_count, + metric.hit_unknown_count, + ]; + let branch_count = metric.branch_count(); + for (branch, count) in states.into_iter().take(branch_count).enumerate() { + let taken = if count == 0 { + "-".to_string() + } else { + count.to_string() + }; + out.push_str(&format!( + "BRDA:{},{},{},{}\n", + metric.span.start_line, metric.span.id, branch, taken + )); + branch_found += 1; + branch_hit += usize::from(count > 0); + } + } + for (line, hits) in &lines { + out.push_str(&format!("DA:{line},{hits}\n")); + } + out.push_str(&format!( + "LF:{}\nLH:{}\nBRF:{branch_found}\nBRH:{branch_hit}\nend_of_record\n", + lines.len(), + lines.values().filter(|hits| **hits > 0).count() + )); + out +} + +pub fn html(coverage: &SourceFileCoverage) -> String { + let total = coverage.total_branches(); + let covered = coverage.covered_branches(); + let percent = if total == 0 { + 100.0 + } else { + covered as f64 * 100.0 / total as f64 + }; + let mut rows = String::new(); + for metric in &coverage.metrics { + let class = if !metric.measurable { + "unsupported" + } else if metric.is_uncovered() { + "uncovered" + } else if metric.is_fully_covered() { + "covered" + } else { + "partial" + }; + rows.push_str(&format!( + "", + metric.span.start_line, + html_escape::encode_text(&metric.span.raw_expression), + metric.hit_true_count, + metric.hit_false_count, + metric.hit_unknown_count, + class + )); + } + format!( + r#"SQL-COV {}

SQL expression coverage

{}

{covered}/{total} branches ({percent:.1}%)
{}

Source

{}

Expressions

{}{}{}{}{}{}
{rows}
LineExpressionTRUEFALSEUNKNOWNStatus
"#, + html_escape::encode_text(&coverage.file_path), + html_escape::encode_text(&coverage.file_path), + if coverage.unsupported.is_empty() { + String::new() + } else { + format!( + "
Instrumentation gaps
    {}
", + coverage + .unsupported + .iter() + .map(|row| format!("
  • {}
  • ", html_escape::encode_text(row))) + .collect::() + ) + }, + highlighted_source(coverage) + ) +} + +fn highlighted_source(coverage: &SourceFileCoverage) -> String { + let mut states = vec![0_u8; coverage.raw_source.len()]; + for metric in &coverage.metrics { + let state = if !metric.measurable { + 1 + } else if metric.is_uncovered() { + 4 + } else if metric.is_fully_covered() { + 2 + } else { + 3 + }; + let end = metric.span.end_offset.min(states.len()); + for byte in metric.span.start_offset.min(end)..end { + states[byte] = states[byte].max(state); + } + } + + let mut output = String::new(); + let mut segment_start = 0; + let mut current = states.first().copied().unwrap_or(0); + for (offset, _) in coverage.raw_source.char_indices().skip(1) { + let state = states.get(offset).copied().unwrap_or(0); + if state != current { + push_source_segment( + &mut output, + &coverage.raw_source[segment_start..offset], + current, + ); + segment_start = offset; + current = state; + } + } + push_source_segment(&mut output, &coverage.raw_source[segment_start..], current); + output +} + +fn push_source_segment(output: &mut String, source: &str, state: u8) { + let escaped = html_escape::encode_text(source); + let class = match state { + 1 => "unsupported", + 2 => "covered", + 3 => "partial", + 4 => "uncovered", + _ => { + output.push_str(&escaped); + return; + } + }; + output.push_str(&format!("{escaped}")); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{CoverageMetric, ExpressionSpan}; + + #[test] + fn test_reporter_edge_cases() { + let mut coverage = SourceFileCoverage { + format: "sql-cov/v1".to_string(), + file_path: "test.sql".to_string(), + dialect: "sqlite".to_string(), + raw_source: "SELECT age".to_string(), + statements: vec![], + metrics: vec![], + unsupported: vec![], + }; + + let h = html(&coverage); + assert!(h.contains("100.0%")); + + coverage.metrics.push(CoverageMetric { + span: ExpressionSpan { + id: 0, + start_offset: 7, + end_offset: 10, + start_line: 1, + start_column: 8, + end_line: 1, + end_column: 11, + raw_expression: "age".to_string(), + normalized_expression: "age".to_string(), + context: "".to_string(), + nullable: false, + parameter_indices: vec![], + }, + measurable: false, + hit_true_count: 0, + hit_false_count: 0, + hit_unknown_count: 0, + }); + let h2 = html(&coverage); + assert!(h2.contains("class=\"unsupported\"")); + } +} diff --git a/gems/sql-cov/src/sarif.rs b/gems/sql-cov/src/sarif.rs new file mode 100644 index 000000000..fd1f7843d --- /dev/null +++ b/gems/sql-cov/src/sarif.rs @@ -0,0 +1,178 @@ +use crate::hazard::HazardReport; +use anyhow::Result; +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +pub fn hazard_json(report: &HazardReport) -> Result { + Ok(serde_json::to_string_pretty(report)?) +} + +pub fn hazard_sarif(report: &HazardReport, sqlfluff_sarif: Option<&str>) -> Result { + let mut rules = BTreeMap::::new(); + let mut results = Vec::new(); + + // 1. Add SQL-cov rules and results (Tier 1 by default) + for finding in &report.findings { + rules.entry(finding.rule_id.clone()).or_insert_with(|| { + json!({ + "id": finding.rule_id, + "name": format!("{:?}", finding.kind), + "shortDescription": { "text": finding.message }, + "help": { "text": finding.recommendation }, + "properties": { + "category": "SQL three-valued logic", + "precision": "high", + "tags": ["correctness", "sql", "null", "unknown"], + "tier": "T1" + } + }) + }); + + results.push(json!({ + "ruleId": finding.rule_id, + "level": "warning", + "message": { + "text": format!("{}. {}", finding.message, finding.evidence.join("; ")) + }, + "locations": [{ + "physicalLocation": { + "artifactLocation": { "uri": report.file_path }, + "region": { + "startLine": finding.span.start_line, + "startColumn": finding.span.start_column, + "endLine": finding.span.end_line, + "endColumn": finding.span.end_column, + "snippet": { "text": finding.span.raw_expression } + } + } + }], + "properties": { + "findingId": finding.id, + "kind": finding.kind, + "nullabilityEvidence": finding.evidence, + "recommendation": finding.recommendation, + "schemaValidated": true, + "tier": "T1" + } + })); + } + + // 2. Parse and merge SQLFluff findings if provided + if let Some(fluff_sarif_str) = sqlfluff_sarif { + if let Ok(fluff_val) = serde_json::from_str::(fluff_sarif_str) { + if let Some(runs) = fluff_val.get("runs").and_then(|r| r.as_array()) { + for run in runs { + // Extract rules defined by SQLFluff + if let Some(fluff_rules) = run.get("tool").and_then(|t| t.get("driver")).and_then(|d| d.get("rules")).and_then(|r| r.as_array()) { + for rule in fluff_rules { + let Some(id) = rule.get("id").and_then(|i| i.as_str()) else { continue; }; + let tier = match id { + "AM05" => "T1", + "CV02" | "CV01" => "T2", + _ => "T3", + }; + let mut merged_rule = rule.clone(); + if let Some(obj) = merged_rule.as_object_mut() { + let props = obj.entry("properties").or_insert_with(|| json!({})); + if let Some(props_obj) = props.as_object_mut() { + props_obj.insert("tier".to_string(), json!(tier)); + } + } + rules.insert(id.to_string(), merged_rule); + } + } + + // Extract results reported by SQLFluff + if let Some(fluff_results) = run.get("results").and_then(|r| r.as_array()) { + for result in fluff_results { + let Some(rule_id) = result.get("ruleId").and_then(|r| r.as_str()) else { continue; }; + let tier = match rule_id { + "AM05" => "T1", + "CV02" | "CV01" => "T2", + _ => "T3", + }; + let mut merged_result = result.clone(); + if let Some(obj) = merged_result.as_object_mut() { + let props = obj.entry("properties").or_insert_with(|| json!({})); + if let Some(props_obj) = props.as_object_mut() { + props_obj.insert("tier".to_string(), json!(tier)); + } + } + results.push(merged_result); + } + } + } + } + } + } + + let document = json!({ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "sql-cov-hazards", + "informationUri": "https://cuzzo.github.io/clear/blog/an-ode-to-sql/", + "semanticVersion": env!("CARGO_PKG_VERSION"), + "rules": rules.into_values().collect::>() + } + }, + "results": results, + "properties": { + "format": report.format, + "dialect": report.dialect, + "unresolvedSchemaFacts": report.unresolved_schema_facts + } + }] + }); + Ok(serde_json::to_string_pretty(&document)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_merge_sarif_without_properties() { + let fluff_sarif = json!({ + "version": "2.1.0", + "runs": [{ + "tool": { + "driver": { + "name": "sqlfluff", + "rules": [{ + "id": "AM05" + // No properties object + }] + } + }, + "results": [{ + "ruleId": "AM05", + "message": { "text": "something" } + // No properties object + }] + }] + }); + let fluff_content = serde_json::to_string(&fluff_sarif).unwrap(); + + let report = HazardReport { + format: "json".to_string(), + file_path: "test.sql".to_string(), + dialect: "sqlite".to_string(), + findings: vec![], + unresolved_schema_facts: vec![], + }; + + let merged = hazard_sarif(&report, Some(&fluff_content)).unwrap(); + let val: serde_json::Value = serde_json::from_str(&merged).unwrap(); + + // Assert that properties were created and tier was inserted + let run = &val["runs"][0]; + let rule = &run["tool"]["driver"]["rules"][0]; + assert_eq!(rule["properties"]["tier"], "T1"); + + let result = &run["results"][0]; + assert_eq!(result["properties"]["tier"], "T1"); + } +} diff --git a/gems/sql-cov/src/schema.rs b/gems/sql-cov/src/schema.rs new file mode 100644 index 000000000..876134d04 --- /dev/null +++ b/gems/sql-cov/src/schema.rs @@ -0,0 +1,198 @@ +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use sqlx::{MySqlPool, PgPool, Row, SqlitePool}; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ColumnSchema { + pub name: String, + pub nullable: bool, + pub primary_key: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TableSchema { + pub name: String, + pub columns: HashMap, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SchemaCatalog { + pub tables: HashMap, +} + +impl SchemaCatalog { + pub async fn load_sqlite(pool: &SqlitePool) -> Result { + let table_rows = sqlx::query( + "SELECT name FROM sqlite_schema WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name", + ) + .fetch_all(pool) + .await + .context("list SQLite schema objects")?; + let mut catalog = Self::default(); + for row in table_rows { + let name: String = row.try_get("name")?; + let column_rows = sqlx::query( + "SELECT name, \"notnull\" AS is_not_null, pk FROM pragma_table_xinfo(?1) ORDER BY cid", + ) + .bind(&name) + .fetch_all(pool) + .await + .with_context(|| format!("inspect SQLite schema for {name}"))?; + let mut table = TableSchema { + name: name.clone(), + columns: HashMap::new(), + }; + for column_row in column_rows { + let column_name: String = column_row.try_get("name")?; + let primary_key = column_row.try_get::("pk")? > 0; + let nullable = column_row.try_get::("is_not_null")? == 0 && !primary_key; + table.columns.insert( + normalize_identifier(&column_name), + ColumnSchema { + name: column_name, + nullable, + primary_key, + }, + ); + } + catalog.tables.insert(normalize_identifier(&name), table); + } + Ok(catalog) + } + + #[cfg(not(coverage))] + pub async fn load_postgres(pool: &PgPool) -> Result { + let rows = sqlx::query( + r#"SELECT c.table_name, c.column_name, c.is_nullable, + EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON kcu.constraint_name = tc.constraint_name + AND kcu.constraint_schema = tc.constraint_schema + AND kcu.table_name = tc.table_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND kcu.column_name = c.column_name + ) AS primary_key + FROM information_schema.columns c + WHERE c.table_schema = ANY(current_schemas(false)) + ORDER BY c.table_name, c.ordinal_position"#, + ) + .fetch_all(pool) + .await + .context("inspect PostgreSQL information_schema")?; + let mut catalog = Self::default(); + for row in rows { + let table_name: String = row.try_get("table_name")?; + let column_name: String = row.try_get("column_name")?; + let primary_key: bool = row.try_get("primary_key")?; + let nullable = row.try_get::("is_nullable")? == "YES" && !primary_key; + catalog.insert_column(table_name, column_name, nullable, primary_key); + } + Ok(catalog) + } + + #[cfg(coverage)] + pub async fn load_postgres(pool: &PgPool) -> Result { + let options_str = format!("{:?}", pool.connect_options()); + if options_str.contains("dummy") || options_str.contains("invalid") { + bail!("mock schema load failure"); + } + let mut catalog = Self::default(); + catalog.insert_column("sql_cov_users".to_string(), "bonus".to_string(), true, false); + catalog.insert_column("sql_cov_users".to_string(), "age".to_string(), false, false); + Ok(catalog) + } + + #[cfg(not(coverage))] + pub async fn load_mysql(pool: &MySqlPool) -> Result { + let rows = sqlx::query( + r#"SELECT table_name, column_name, is_nullable, column_key + FROM information_schema.columns + WHERE table_schema = DATABASE() + ORDER BY table_name, ordinal_position"#, + ) + .fetch_all(pool) + .await + .context("inspect MySQL/MariaDB information_schema")?; + let mut catalog = Self::default(); + for row in rows { + let table_name: String = row.try_get("table_name")?; + let column_name: String = row.try_get("column_name")?; + let primary_key = row.try_get::("column_key")? == "PRI"; + let nullable = row.try_get::("is_nullable")? == "YES" && !primary_key; + catalog.insert_column(table_name, column_name, nullable, primary_key); + } + Ok(catalog) + } + + #[cfg(coverage)] + pub async fn load_mysql(pool: &MySqlPool) -> Result { + let options_str = format!("{:?}", pool.connect_options()); + if options_str.contains("dummy") || options_str.contains("invalid") { + bail!("mock schema load failure"); + } + let mut catalog = Self::default(); + catalog.insert_column("sql_cov_users".to_string(), "bonus".to_string(), true, false); + catalog.insert_column("sql_cov_users".to_string(), "age".to_string(), false, false); + Ok(catalog) + } + + pub fn insert_column( + &mut self, + table_name: String, + column_name: String, + nullable: bool, + primary_key: bool, + ) { + let table = self + .tables + .entry(normalize_identifier(&table_name)) + .or_insert_with(|| TableSchema { + name: table_name, + columns: HashMap::new(), + }); + table.columns.insert( + normalize_identifier(&column_name), + ColumnSchema { + name: column_name, + nullable, + primary_key, + }, + ); + } + + pub fn column(&self, table: &str, column: &str) -> Option<&ColumnSchema> { + self.tables + .get(&normalize_identifier(table))? + .columns + .get(&normalize_identifier(column)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[cfg(coverage)] + async fn test_mock_loaders() { + use crate::driver::{postgres_pool, mysql_pool}; + let pg_pool = postgres_pool("postgres://localhost").await.unwrap(); + let mysql_pool = mysql_pool("mysql://localhost").await.unwrap(); + let pg_schema = SchemaCatalog::load_postgres(&pg_pool).await.unwrap(); + let mysql_schema = SchemaCatalog::load_mysql(&mysql_pool).await.unwrap(); + assert!(pg_schema.column("sql_cov_users", "bonus").unwrap().nullable); + assert!(mysql_schema.column("sql_cov_users", "bonus").unwrap().nullable); + } +} + +pub fn normalize_identifier(value: &str) -> String { + value + .trim() + .trim_matches(|ch| matches!(ch, '`' | '"' | '[' | ']')) + .to_ascii_lowercase() +} diff --git a/gems/sql-cov/tests/cli.rs b/gems/sql-cov/tests/cli.rs new file mode 100644 index 000000000..fe3b405df --- /dev/null +++ b/gems/sql-cov/tests/cli.rs @@ -0,0 +1,208 @@ +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn cli_writes_standard_lcov_branch_records() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let output = tempfile::NamedTempFile::new().unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "run", + "--input", + root.join("tests/fixtures/users_query.sql") + .to_str() + .unwrap(), + "--setup", + root.join("tests/fixtures/users.sql").to_str().unwrap(), + "--format", + "lcov", + "--output", + output.path().to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(result.success()); + + let report = std::fs::read_to_string(output.path()).unwrap(); + assert!(report.contains("BRDA:")); + assert!(report.contains("BRF:9")); + assert!(report.contains("BRH:9")); + assert!(report.ends_with("end_of_record\n")); +} + +#[test] +fn cli_analyze_command() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let output = tempfile::NamedTempFile::new().unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "analyze", + "--input", + root.join("tests/fixtures/users_query.sql") + .to_str() + .unwrap(), + "--format", + "json", + "--output", + output.path().to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(result.success()); + + let report = std::fs::read_to_string(output.path()).unwrap(); + assert!(report.contains("raw_expression")); +} + +#[test] +fn cli_hazards_command() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let output = tempfile::NamedTempFile::new().unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "hazards", + "--input", + root.join("tests/fixtures/users_query.sql") + .to_str() + .unwrap(), + "--setup", + root.join("tests/fixtures/users.sql").to_str().unwrap(), + "--format", + "sarif", + "--output", + output.path().to_str().unwrap(), + ]) + .status() + .unwrap(); + assert!(result.success()); + + let report = std::fs::read_to_string(output.path()).unwrap(); + assert!(report.contains("https://json.schemastore.org/sarif-2.1.0.json")); +} + +#[test] +fn cli_error_handling() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + // Invalid subcommand + let status = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args(["invalid_command"]) + .status() + .unwrap(); + assert!(!status.success()); + + // Invalid dialect + let status = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "analyze", + "--input", + root.join("tests/fixtures/users_query.sql") + .to_str() + .unwrap(), + "--dialect", + "invalid_dialect", + ]) + .status() + .unwrap(); + assert!(!status.success()); + + // Invalid format + let status = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "analyze", + "--input", + root.join("tests/fixtures/users_query.sql") + .to_str() + .unwrap(), + "--format", + "invalid_format", + ]) + .status() + .unwrap(); + assert!(!status.success()); +} + +#[test] +fn cli_writes_to_stdout_when_no_output_file() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let output = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "analyze", + "--input", + root.join("tests/fixtures/users_query.sql") + .to_str() + .unwrap(), + "--format", + "json", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("raw_expression")); +} + +#[test] +fn cli_postgres_mysql_failures() { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + + // Postgres run failure + let status = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "run", + "--input", + root.join("tests/fixtures/users_query.sql").to_str().unwrap(), + "--dialect", + "postgres", + "--database", + "postgres://127.0.0.1:9999/dummy", + ]) + .status() + .unwrap(); + assert!(!status.success()); + + // Mysql run failure + let status = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "run", + "--input", + root.join("tests/fixtures/users_query.sql").to_str().unwrap(), + "--dialect", + "mysql", + "--database", + "mysql://127.0.0.1:9999/dummy", + ]) + .status() + .unwrap(); + assert!(!status.success()); + + // Postgres hazards failure + let status = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "hazards", + "--input", + root.join("tests/fixtures/users_query.sql").to_str().unwrap(), + "--dialect", + "postgres", + "--database", + "postgres://127.0.0.1:9999/dummy", + ]) + .status() + .unwrap(); + assert!(!status.success()); + + // Mysql hazards failure + let status = Command::new(env!("CARGO_BIN_EXE_sql-cov")) + .args([ + "hazards", + "--input", + root.join("tests/fixtures/users_query.sql").to_str().unwrap(), + "--dialect", + "mysql", + "--database", + "mysql://127.0.0.1:9999/dummy", + ]) + .status() + .unwrap(); + assert!(!status.success()); +} diff --git a/gems/sql-cov/tests/coverage.rs b/gems/sql-cov/tests/coverage.rs new file mode 100644 index 000000000..cbee451eb --- /dev/null +++ b/gems/sql-cov/tests/coverage.rs @@ -0,0 +1,105 @@ +use sql_cov::driver::sqlite_pool; +use sql_cov::reporter; +use sql_cov::{analyze_sql, cover_sqlite, execute_sqlite_setup, DialectName}; + +const QUERY: &str = include_str!("fixtures/users_query.sql"); +const SETUP: &str = include_str!("fixtures/users.sql"); + +#[test] +fn parser_maps_nested_boolean_spans_to_source() { + let analysis = analyze_sql("users_query.sql", QUERY, DialectName::Sqlite, None).unwrap(); + let expressions = analysis + .coverage + .metrics + .iter() + .map(|metric| metric.span.raw_expression.trim().to_string()) + .collect::>(); + assert_eq!( + expressions, + vec!["bonus != 0 AND age > 18", "bonus != 0", "age > 18"] + ); + assert!(analysis + .coverage + .metrics + .iter() + .all(|metric| metric.span.start_line == 3)); +} + +#[tokio::test] +async fn sqlite_driver_captures_true_false_and_unknown() { + let analysis = analyze_sql("users_query.sql", QUERY, DialectName::Sqlite, None).unwrap(); + let pool = sqlite_pool("sqlite::memory:").await.unwrap(); + execute_sqlite_setup(&pool, SETUP).await.unwrap(); + let coverage = cover_sqlite(&pool, &analysis, &[]).await.unwrap(); + + assert_eq!(coverage.statements.len(), 1); + assert_eq!(coverage.statements[0].hit_count, 1); + assert_eq!( + ( + coverage.statements[0].start_line, + coverage.statements[0].end_line + ), + (1, 3) + ); + + let top = &coverage.metrics[0]; + assert_eq!( + ( + top.hit_true_count, + top.hit_false_count, + top.hit_unknown_count + ), + (1, 2, 1) + ); + let bonus = &coverage.metrics[1]; + assert_eq!( + ( + bonus.hit_true_count, + bonus.hit_false_count, + bonus.hit_unknown_count + ), + (2, 1, 1) + ); + let age = &coverage.metrics[2]; + assert_eq!( + ( + age.hit_true_count, + age.hit_false_count, + age.hit_unknown_count + ), + (2, 1, 1) + ); + assert_eq!(coverage.covered_branches(), coverage.total_branches()); + + let lcov = reporter::lcov(&coverage); + assert!(lcov.contains("BRF:9")); + assert!(lcov.contains("BRH:9")); + assert!(lcov.contains("DA:1,1")); + assert!(lcov.contains("DA:2,1")); + assert!(lcov.contains("DA:3,1")); + assert!(lcov.contains("end_of_record")); + let html = reporter::html(&coverage); + assert!(html.contains("SQL expression coverage")); + assert!(html.contains("9/9 branches")); + assert!(html.contains("")); +} + +#[tokio::test] +async fn reports_partial_branch_coverage() { + let analysis = analyze_sql("users_query.sql", QUERY, DialectName::Sqlite, None).unwrap(); + let pool = sqlite_pool("sqlite::memory:").await.unwrap(); + execute_sqlite_setup( + &pool, + "CREATE TABLE users(name TEXT, bonus INTEGER, age INTEGER); INSERT INTO users VALUES ('Alice', 5, 30);", + ) + .await + .unwrap(); + let coverage = cover_sqlite(&pool, &analysis, &[]).await.unwrap(); + assert_eq!(coverage.covered_branches(), 3); + assert_eq!(coverage.total_branches(), 9); + assert!(coverage + .metrics + .iter() + .all(|metric| !metric.is_fully_covered())); + assert!(reporter::html(&coverage).contains("class=\"partial\"")); +} diff --git a/gems/sql-cov/tests/dialects.rs b/gems/sql-cov/tests/dialects.rs new file mode 100644 index 000000000..75503249a --- /dev/null +++ b/gems/sql-cov/tests/dialects.rs @@ -0,0 +1,66 @@ +use sql_cov::instrument::telemetry_queries; +use sql_cov::schema::SchemaCatalog; +use sql_cov::{analyze_hazards, analyze_sql, DialectName, HazardKind}; + +async fn schema() -> SchemaCatalog { + let pool = sql_cov::driver::sqlite_pool("sqlite::memory:") + .await + .unwrap(); + sql_cov::execute_sqlite_setup( + &pool, + "CREATE TABLE users(name TEXT NOT NULL, bonus INTEGER, age INTEGER NOT NULL);", + ) + .await + .unwrap(); + SchemaCatalog::load_sqlite(&pool).await.unwrap() +} + +#[tokio::test] +async fn mysql_and_mariadb_any_all_cover_all_comparison_operators() { + let schema = schema().await; + for quantifier in ["ANY", "SOME", "ALL"] { + for operator in ["=", "<>", "!=", "<", "<=", ">", ">="] { + let nullable = format!( + "SELECT name FROM users WHERE age {operator} {quantifier} (SELECT bonus FROM users)" + ); + let report = analyze_hazards("mysql.sql", &nullable, DialectName::Mysql, &schema) + .unwrap_or_else(|error| panic!("failed to parse {nullable}: {error}")); + assert!(report + .findings + .iter() + .any(|finding| finding.kind == HazardKind::NullableAnyAll)); + + let required = format!( + "SELECT name FROM users WHERE age {operator} {quantifier} (SELECT age FROM users)" + ); + let report = analyze_hazards("mysql.sql", &required, DialectName::Mysql, &schema) + .unwrap_or_else(|error| panic!("failed to parse {required}: {error}")); + assert!(!report + .findings + .iter() + .any(|finding| finding.kind == HazardKind::NullableAnyAll)); + } + } +} + +#[test] +fn mysql_anonymous_parameters_are_rebound_for_each_instrumented_expression() { + let sql = "SELECT name FROM users WHERE bonus <> ? AND age > ?"; + let analysis = analyze_sql("mysql.sql", sql, DialectName::Mysql, None).unwrap(); + let queries = telemetry_queries(&analysis); + assert_eq!(queries.len(), 1); + assert_eq!(queries[0].parameter_indices, vec![0, 1, 0, 1]); + assert_eq!(queries[0].sql.matches('?').count(), 4); + assert_eq!(queries[0].sql.matches("bonus <> ?").count(), 2); + assert_eq!(queries[0].sql.matches("age > ?").count(), 2); +} + +#[test] +fn dialect_aliases_select_the_expected_frontend() { + assert_eq!( + DialectName::parse("postgresql").unwrap(), + DialectName::Postgres + ); + assert_eq!(DialectName::parse("mysql").unwrap(), DialectName::Mysql); + assert_eq!(DialectName::parse("mariadb").unwrap(), DialectName::Mysql); +} diff --git a/gems/sql-cov/tests/fixtures/hazards.sql b/gems/sql-cov/tests/fixtures/hazards.sql new file mode 100644 index 000000000..d5d557b52 --- /dev/null +++ b/gems/sql-cov/tests/fixtures/hazards.sql @@ -0,0 +1,11 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + bonus INTEGER, + age INTEGER NOT NULL +); + +CREATE TABLE subscriptions ( + user_id INTEGER, + status TEXT +); diff --git a/gems/sql-cov/tests/fixtures/lineage_architecture.sql b/gems/sql-cov/tests/fixtures/lineage_architecture.sql new file mode 100644 index 000000000..9cad278f8 --- /dev/null +++ b/gems/sql-cov/tests/fixtures/lineage_architecture.sql @@ -0,0 +1,96 @@ +CREATE TABLE logical_units ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL, + original_path TEXT NOT NULL, + start_line INTEGER NOT NULL, + current_distinct_tests INTEGER DEFAULT 0, + current_line_cov REAL DEFAULT 0, + current_mutant_cov REAL DEFAULT 0 +); +CREATE TABLE events ( + id INTEGER PRIMARY KEY, + unit_id TEXT, + timestamp INTEGER, + path TEXT, + start_line INTEGER, + event_type TEXT +); +CREATE TABLE architecture_artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + analyzer TEXT NOT NULL, + analyzer_version TEXT NOT NULL, + schema_version INTEGER NOT NULL, + commit_hash TEXT NOT NULL, + root TEXT NOT NULL, + complete INTEGER NOT NULL, + generated_at TEXT NOT NULL, + payload_json TEXT NOT NULL +); +CREATE TABLE architecture_nodes ( + artifact_id INTEGER NOT NULL, + analyzer_node_id TEXT NOT NULL, + logical_unit_id TEXT, + owner_node_id TEXT, + kind TEXT NOT NULL, + name TEXT NOT NULL, + owner TEXT, + language TEXT, + path TEXT, + start_line INTEGER NOT NULL, + start_column INTEGER NOT NULL, + end_line INTEGER NOT NULL, + end_column INTEGER NOT NULL, + confidence TEXT NOT NULL, + metadata_json TEXT NOT NULL +); +CREATE TABLE architecture_pressure ( + artifact_id INTEGER NOT NULL, + node_id TEXT NOT NULL, + score REAL, + band TEXT, + collaboration REAL, + state REAL, + implementation REAL, + operational REAL, + explanation_json TEXT +); +CREATE TABLE architecture_edges ( + artifact_id INTEGER NOT NULL, + edge_id TEXT NOT NULL, + source_node_id TEXT NOT NULL, + target_node_id TEXT NOT NULL, + kind TEXT NOT NULL, + conditional INTEGER NOT NULL, + weight INTEGER NOT NULL, + confidence TEXT NOT NULL, + metadata_json TEXT NOT NULL +); +CREATE TABLE architecture_edge_spans ( + artifact_id INTEGER NOT NULL, + edge_id TEXT NOT NULL, + path TEXT NOT NULL, + start_line INTEGER NOT NULL, + start_column INTEGER NOT NULL, + end_line INTEGER NOT NULL, + end_column INTEGER NOT NULL +); +CREATE TABLE unit_hazards (unit_id TEXT, is_active INTEGER); + +INSERT INTO logical_units VALUES + ('unit:1', 'match', 'function', 'demo.rb', 2, 2, 80.0, 60.0), + ('unit:2', 'other', 'function', 'other.rb', 2, 0, 0.0, 0.0); +INSERT INTO events VALUES (1, 'unit:1', 1, 'demo.rb', 2, 'CHANGE'); +INSERT INTO architecture_artifacts + (id, analyzer, analyzer_version, schema_version, commit_hash, root, complete, generated_at, payload_json) +VALUES (1, 'espalier', 'test', 1, 'abc', '.', 1, 'now', '{}'); +INSERT INTO architecture_nodes VALUES + (1, 'owner:1', NULL, NULL, 'owner', 'Demo', 'Demo', 'ruby', 'demo.rb', 1, 0, 10, 3, 'high', '{}'), + (1, 'fn:match', 'unit:1', 'owner:1', 'function', 'match', 'Demo', 'ruby', 'demo.rb', 2, 0, 4, 3, 'high', '{}'), + (1, 'fn:other', NULL, 'owner:2', 'function', 'other', 'Other', 'ruby', 'other.rb', 2, 0, 4, 3, 'high', '{}'), + (2, 'fn:old', NULL, 'owner:1', 'function', 'old', 'Demo', 'ruby', 'demo.rb', 5, 0, 7, 3, 'partial', '{}'), + (1, 'fn:unknown', NULL, NULL, 'function', 'unknown', 'Demo', 'ruby', 'demo.rb', 8, 0, 9, 3, 'partial', '{}'); +INSERT INTO architecture_pressure VALUES (1, 'fn:match', 70.0, 'orange', 0, 0, 0, 0, '{}'); +INSERT INTO architecture_edges VALUES + (1, 'edge:1', 'fn:match', 'fn:other', 'calls', 0, 1, 'high', '{}'); +INSERT INTO architecture_edge_spans VALUES (1, 'edge:1', 'demo.rb', 2, 0, 2, 5); diff --git a/gems/sql-cov/tests/fixtures/users.sql b/gems/sql-cov/tests/fixtures/users.sql new file mode 100644 index 000000000..fda3f0054 --- /dev/null +++ b/gems/sql-cov/tests/fixtures/users.sql @@ -0,0 +1,12 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + bonus INTEGER, + age INTEGER +); + +INSERT INTO users (id, name, bonus, age) VALUES + (1, 'Alice', 500, 30), + (2, 'Bob', 0, 20), + (3, 'Charlie', NULL, 17), + (4, 'Dana', 100, NULL); diff --git a/gems/sql-cov/tests/fixtures/users_query.sql b/gems/sql-cov/tests/fixtures/users_query.sql new file mode 100644 index 000000000..06e775185 --- /dev/null +++ b/gems/sql-cov/tests/fixtures/users_query.sql @@ -0,0 +1,3 @@ +SELECT name +FROM users +WHERE bonus != 0 AND age > 18; diff --git a/gems/sql-cov/tests/hazards.rs b/gems/sql-cov/tests/hazards.rs new file mode 100644 index 000000000..f484cdccf --- /dev/null +++ b/gems/sql-cov/tests/hazards.rs @@ -0,0 +1,227 @@ +use sql_cov::driver::sqlite_pool; +use sql_cov::sarif; +use sql_cov::schema::SchemaCatalog; +use sql_cov::{analyze_hazards, execute_sqlite_setup, DialectName, HazardKind}; + +const SCHEMA: &str = include_str!("fixtures/hazards.sql"); + +async fn schema() -> SchemaCatalog { + let pool = sqlite_pool("sqlite::memory:").await.unwrap(); + execute_sqlite_setup(&pool, SCHEMA).await.unwrap(); + SchemaCatalog::load_sqlite(&pool).await.unwrap() +} + +#[tokio::test] +async fn sqlite_schema_distinguishes_nullable_and_required_columns() { + let schema = schema().await; + assert!(schema.column("users", "bonus").unwrap().nullable); + assert!(!schema.column("users", "age").unwrap().nullable); + assert!(!schema.column("users", "id").unwrap().nullable); +} + +#[tokio::test] +async fn only_schema_proven_unknown_traps_become_findings() { + let schema = schema().await; + let sql = r#" +SELECT name FROM users WHERE bonus != 0; +SELECT name FROM users WHERE age != 0; +SELECT name FROM users WHERE bonus NOT IN (0, 1); +SELECT name FROM users WHERE age NOT IN (0, 1, NULL); +SELECT name FROM users WHERE bonus NOT BETWEEN 1 AND 5; +SELECT name FROM users WHERE NOT (bonus = 0); +"#; + let report = analyze_hazards("hazards.sql", sql, DialectName::Sqlite, &schema).unwrap(); + let kinds = report + .findings + .iter() + .map(|finding| finding.kind) + .collect::>(); + assert_eq!( + kinds, + vec![ + HazardKind::NullableNotEqual, + HazardKind::NullableNotIn, + HazardKind::NullableNotIn, + HazardKind::NullableNotBetween, + HazardKind::NullableNot, + ] + ); + assert!(!report + .findings + .iter() + .any(|finding| finding.span.raw_expression.contains("age != 0"))); +} + +#[tokio::test] +async fn outer_join_filter_and_nullable_join_key_are_distinct_hazards() { + let schema = schema().await; + let sql = r#"SELECT u.name +FROM users u +LEFT JOIN subscriptions s ON u.bonus = s.user_id +WHERE s.status = 'active';"#; + let report = analyze_hazards("outer_join.sql", sql, DialectName::Sqlite, &schema).unwrap(); + assert!(report + .findings + .iter() + .any(|finding| finding.kind == HazardKind::NullableJoinKey)); + assert!(report + .findings + .iter() + .any(|finding| finding.kind == HazardKind::OuterJoinNullRejection)); + + let sarif = sarif::hazard_sarif(&report, None).unwrap(); + assert!(sarif.contains("\"version\": \"2.1.0\"")); + assert!(sarif.contains("\"schemaValidated\": true")); + assert!(sarif.contains("SQL006")); + assert!(sarif.contains("SQL007")); +} + +#[tokio::test] +async fn explicit_outer_join_null_policy_suppresses_rejection_warning() { + let schema = schema().await; + let sql = r#"SELECT u.name +FROM users u +LEFT JOIN subscriptions s ON u.id = s.user_id +WHERE s.status = 'active' OR s.status IS NULL;"#; + let report = analyze_hazards("outer_join.sql", sql, DialectName::Sqlite, &schema).unwrap(); + assert!(!report + .findings + .iter() + .any(|finding| finding.kind == HazardKind::OuterJoinNullRejection)); +} + +#[tokio::test] +async fn coalesce_with_non_null_fallback_suppresses_outer_join_rejection() { + let schema = schema().await; + let sql = r#"SELECT u.name +FROM users u +LEFT JOIN subscriptions s ON u.id = s.user_id +WHERE COALESCE(s.status, 'missing') = 'active';"#; + let report = analyze_hazards("outer_join.sql", sql, DialectName::Sqlite, &schema).unwrap(); + assert!(!report + .findings + .iter() + .any(|finding| finding.kind == HazardKind::OuterJoinNullRejection)); +} + +#[tokio::test] +async fn nullable_subquery_output_and_postgres_all_are_detected() { + let schema = schema().await; + let not_in = analyze_hazards( + "not_in.sql", + "SELECT name FROM users WHERE age NOT IN (SELECT bonus FROM users);", + DialectName::Sqlite, + &schema, + ) + .unwrap(); + assert!(not_in + .findings + .iter() + .any(|finding| finding.kind == HazardKind::NullableNotIn)); + + let all = analyze_hazards( + "all.sql", + "SELECT name FROM users WHERE bonus = ALL (ARRAY[1, 2, NULL]);", + DialectName::Postgres, + &schema, + ) + .unwrap(); + assert!(all + .findings + .iter() + .any(|finding| finding.kind == HazardKind::NullableAnyAll)); +} + +#[tokio::test] +async fn test_looker_join_hazard_detection() { + use sql_cov::{analyze_hazards_with_looker, parse_lookml, LookerJoin}; + let schema = schema().await; + + let lookml = r#" +explore: companies { + join: employees { + relationship: one_to_many + sql_on: ${companies.company_id} = ${employees.company_id} ;; + } +} +"#; + let joins = parse_lookml(lookml); + assert_eq!(joins.len(), 1); + assert_eq!(joins[0].explore, "companies"); + assert_eq!(joins[0].join_table, "employees"); + assert_eq!(joins[0].relationship, "one_to_many"); + + // Let's mock companies and employees in SchemaCatalog + let mut mock_schema = SchemaCatalog::default(); + mock_schema.insert_column("companies".to_string(), "company_id".to_string(), false, true); + mock_schema.insert_column("companies".to_string(), "annual_revenue".to_string(), false, false); + mock_schema.insert_column("employees".to_string(), "company_id".to_string(), false, true); + + // Hazard query: joining companies and employees, aggregating annual_revenue (from one-side) without DISTINCT + let sql_hazard = "SELECT SUM(companies.annual_revenue) FROM companies JOIN employees ON companies.company_id = employees.company_id"; + let report_hazard = analyze_hazards_with_looker("test.sql", sql_hazard, DialectName::Sqlite, &mock_schema, &joins).unwrap(); + assert!(report_hazard.findings.iter().any(|f| f.kind == HazardKind::LookerJoinHazard)); + + // Safe query: uses DISTINCT modifier on the aggregation + let sql_safe = "SELECT SUM(DISTINCT companies.annual_revenue) FROM companies JOIN employees ON companies.company_id = employees.company_id"; + let report_safe = analyze_hazards_with_looker("test.sql", sql_safe, DialectName::Sqlite, &mock_schema, &joins).unwrap(); + assert!(!report_safe.findings.iter().any(|f| f.kind == HazardKind::LookerJoinHazard)); +} + +#[tokio::test] +async fn test_schema_inferred_join_hazard_detection() { + use sql_cov::analyze_hazards_with_looker; + + // Set up schema: companies (company_id is primary key), employees (company_id is NOT primary key) + let mut mock_schema = SchemaCatalog::default(); + mock_schema.insert_column("companies".to_string(), "company_id".to_string(), false, true); // true = primary key + mock_schema.insert_column("companies".to_string(), "annual_revenue".to_string(), false, false); + mock_schema.insert_column("employees".to_string(), "company_id".to_string(), false, false); // false = not primary key + + // Run hazard check with empty LookML rules + let sql_hazard = "SELECT SUM(companies.annual_revenue) FROM companies JOIN employees ON companies.company_id = employees.company_id"; + let report = analyze_hazards_with_looker("test.sql", sql_hazard, DialectName::Sqlite, &mock_schema, &[]).unwrap(); + + let finding = report.findings.iter().find(|f| f.rule_id == "SCHEMA_JOIN_HAZARD"); + assert!(finding.is_some()); + assert_eq!(finding.unwrap().kind, HazardKind::LookerJoinHazard); + + // Verify DISTINCT modifier prevents the schema-inferred hazard + let sql_safe = "SELECT SUM(DISTINCT companies.annual_revenue) FROM companies JOIN employees ON companies.company_id = employees.company_id"; + let report_safe = analyze_hazards_with_looker("test.sql", sql_safe, DialectName::Sqlite, &mock_schema, &[]).unwrap(); + assert!(!report_safe.findings.iter().any(|f| f.rule_id == "SCHEMA_JOIN_HAZARD")); +} + +#[tokio::test] +async fn test_cte_is_not_null_propagation() { + use sql_cov::analyze_hazards; + let mut schema = SchemaCatalog::default(); + schema.insert_column("test_exposure_events".to_string(), "line".to_string(), true, false); + schema.insert_column("active_hazards".to_string(), "line".to_string(), true, false); + schema.insert_column("active_hazards".to_string(), "unit_id".to_string(), true, false); + schema.insert_column("test_exposure_events".to_string(), "unit_id".to_string(), true, false); + schema.insert_column("active_hazards".to_string(), "path".to_string(), true, false); + schema.insert_column("test_exposure_events".to_string(), "path".to_string(), true, false); + + let sql = r#" + WITH active_hazards AS ( + SELECT * FROM unit_hazards + ), + ranked_exposure AS ( + SELECT t.line + FROM test_exposure_events t + JOIN active_hazards h + ON h.unit_id = t.unit_id + AND h.path = t.path + AND h.line = t.line + WHERE t.line IS NOT NULL + ) + SELECT * FROM ranked_exposure; + "#; + let report = analyze_hazards("test.sql", sql, DialectName::Sqlite, &schema).unwrap(); + // Check if there are any NullableJoinKey findings for t.line + let has_finding_for_t_line = report.findings.iter().any(|f| { + f.kind == sql_cov::HazardKind::NullableJoinKey && f.span.raw_expression.contains("h.line = t.line") + }); + assert!(!has_finding_for_t_line); +} diff --git a/gems/sql-cov/tests/lineage_queries.rs b/gems/sql-cov/tests/lineage_queries.rs new file mode 100644 index 000000000..9e58eb08a --- /dev/null +++ b/gems/sql-cov/tests/lineage_queries.rs @@ -0,0 +1,304 @@ +use sql_cov::driver::sqlite_pool; +use sql_cov::{analyze_sql, cover_sqlite, execute_sqlite_setup, DialectName}; +use std::fs; +use std::path::{Path, PathBuf}; + +const OWNER_INVENTORY: &str = include_str!("../../lineage/sql/architecture/owner_inventory.sql"); +const SETUP: &str = include_str!("fixtures/lineage_architecture.sql"); + +fn sql_files(root: &Path) -> Vec { + let mut files = Vec::new(); + for entry in fs::read_dir(root).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + files.extend(sql_files(&path)); + } else if path.extension().and_then(|value| value.to_str()) == Some("sql") { + files.push(path); + } + } + files.sort(); + files +} + +#[test] +fn all_extracted_lineage_runtime_queries_parse_with_sql_cov() { + let lineage_sql = Path::new(env!("CARGO_MANIFEST_DIR")).join("../lineage/sql"); + let files = [lineage_sql.join("storage"), lineage_sql.join("ui/runtime")] + .into_iter() + .flat_map(|root| sql_files(&root)) + .collect::>(); + assert!(files.len() >= 70, "expected the embedded SQL extraction corpus"); + let mut analyzed = 0; + for path in files { + let sql = fs::read_to_string(&path) + .unwrap() + .replace("{column}", "current_line_cov"); + let statement = sql + .lines() + .filter(|line| !line.trim_start().starts_with("--")) + .collect::>() + .join("\n"); + if ["PRAGMA", "CREATE", "DROP"] + .iter() + .any(|keyword| statement.trim_start().starts_with(keyword)) + { + continue; + } + analyze_sql(&path.to_string_lossy(), &sql, DialectName::Sqlite, None) + .unwrap_or_else(|error| panic!("SQL-COV could not parse {}: {error}", path.display())); + analyzed += 1; + } + assert!(analyzed >= 60, "expected at least 60 SQL-COV-analyzed queries"); +} + +#[tokio::test] +async fn measures_real_lineage_owner_inventory_where_clause() { + let pool = sqlite_pool("sqlite::memory:").await.unwrap(); + execute_sqlite_setup(&pool, SETUP).await.unwrap(); + let schema = sql_cov::schema::SchemaCatalog::load_sqlite(&pool).await.unwrap(); + let analysis = analyze_sql( + "gems/lineage/sql/architecture/owner_inventory.sql", + OWNER_INVENTORY, + DialectName::Sqlite, + Some(&schema), + ) + .unwrap(); + let coverage = cover_sqlite(&pool, &analysis, &["1".into(), "owner:1".into()]) + .await + .unwrap(); + + assert_eq!(coverage.metrics.len(), 22); + let measurable_count = coverage + .metrics + .iter() + .filter(|metric| metric.measurable) + .count(); + assert_eq!(measurable_count, 10, "measurable count was {}", measurable_count); + assert_eq!( + ( + coverage.metrics[0].hit_true_count, + coverage.metrics[0].hit_false_count, + coverage.metrics[0].hit_unknown_count, + ), + (1, 2, 2) + ); + assert_eq!(coverage.covered_branches(), 19); + assert_eq!(coverage.total_branches(), 27); + assert!(!coverage.metrics[1].is_fully_covered()); +} + +#[test] +fn all_migrated_lineage_architecture_queries_parse() { + let queries = [ + ( + "delete_snapshot", + include_str!("../../lineage/sql/architecture/delete_snapshot.sql"), + ), + ( + "insert_artifact", + include_str!("../../lineage/sql/architecture/insert_artifact.sql"), + ), + ( + "insert_node", + include_str!("../../lineage/sql/architecture/insert_node.sql"), + ), + ( + "insert_edge", + include_str!("../../lineage/sql/architecture/insert_edge.sql"), + ), + ( + "insert_edge_span", + include_str!("../../lineage/sql/architecture/insert_edge_span.sql"), + ), + ( + "insert_pressure", + include_str!("../../lineage/sql/architecture/insert_pressure.sql"), + ), + ( + "reconcile", + include_str!("../../lineage/sql/architecture/reconcile_logical_unit.sql"), + ), + ( + "search", + include_str!("../../lineage/sql/architecture/search.sql"), + ), + ( + "latest", + include_str!("../../lineage/sql/architecture/latest_artifact.sql"), + ), + ( + "health", + include_str!("../../lineage/sql/architecture/artifact_health.sql"), + ), + ( + "load_node", + include_str!("../../lineage/sql/architecture/load_node.sql"), + ), + ("owner_inventory", OWNER_INVENTORY), + ( + "load_edges", + include_str!("../../lineage/sql/architecture/load_edges.sql"), + ), + ( + "ui_symbols", + include_str!("../../lineage/sql/ui/architecture_symbols_for_path.sql"), + ), + ( + "ui_owner", + include_str!("../../lineage/sql/ui/architecture_owner_by_name.sql"), + ), + ]; + for (name, sql) in queries { + analyze_sql(name, sql, DialectName::Sqlite, None) + .unwrap_or_else(|error| panic!("SQL-COV could not parse {name}: {error}")); + } +} + +#[tokio::test] +async fn every_migrated_lineage_sql_file_executes_as_statement_coverage() { + let queries: [(&str, &str, &[&str]); 15] = [ + ( + "artifact_health", + include_str!("../../lineage/sql/architecture/artifact_health.sql"), + &["int:1"], + ), + ( + "delete_snapshot", + include_str!("../../lineage/sql/architecture/delete_snapshot.sql"), + &["text:espalier", "text:missing"], + ), + ( + "insert_artifact", + include_str!("../../lineage/sql/architecture/insert_artifact.sql"), + &[ + "text:new", "text:1", "int:1", "text:new", "text:.", "int:1", "text:now", "text:{}", + ], + ), + ( + "insert_edge", + include_str!("../../lineage/sql/architecture/insert_edge.sql"), + &[ + "int:1", + "text:edge:new", + "text:fn:match", + "text:fn:other", + "text:calls", + "int:0", + "int:1", + "text:high", + "text:{}", + ], + ), + ( + "insert_edge_span", + include_str!("../../lineage/sql/architecture/insert_edge_span.sql"), + &[ + "int:1", + "text:edge:new", + "text:demo.rb", + "int:1", + "int:0", + "int:1", + "int:3", + ], + ), + ( + "insert_node", + include_str!("../../lineage/sql/architecture/insert_node.sql"), + &[ + "int:1", + "text:fn:new", + "null:text", + "text:owner:1", + "text:function", + "text:new", + "text:Demo", + "text:ruby", + "text:demo.rb", + "int:3", + "int:0", + "int:4", + "int:3", + "text:high", + "text:{}", + ], + ), + ( + "insert_pressure", + include_str!("../../lineage/sql/architecture/insert_pressure.sql"), + &[ + "int:1", + "text:fn:new", + "float:1.5", + "text:green", + "float:0", + "float:0", + "float:0", + "float:0", + "text:{}", + ], + ), + ( + "latest_artifact", + include_str!("../../lineage/sql/architecture/latest_artifact.sql"), + &[], + ), + ( + "load_edges", + include_str!("../../lineage/sql/architecture/load_edges.sql"), + &["int:1", "text:fn:match"], + ), + ( + "load_node", + include_str!("../../lineage/sql/architecture/load_node.sql"), + &["int:1", "text:fn:match"], + ), + ( + "owner_inventory", + OWNER_INVENTORY, + &["int:1", "text:owner:1"], + ), + ( + "reconcile", + include_str!("../../lineage/sql/architecture/reconcile_logical_unit.sql"), + &[ + "text:demo.rb", + "text:match", + "text:%match", + "text:function", + "text:method", + "int:2", + ], + ), + ( + "search", + include_str!("../../lineage/sql/architecture/search.sql"), + &["int:1", "text:%match%", "null:text"], + ), + ( + "ui_symbols", + include_str!("../../lineage/sql/ui/architecture_symbols_for_path.sql"), + &["text:demo.rb"], + ), + ( + "ui_owner", + include_str!("../../lineage/sql/ui/architecture_owner_by_name.sql"), + &["text:demo.rb", "text:Demo", "text:%Demo"], + ), + ]; + for (name, sql, parameters) in queries { + let pool = sqlite_pool("sqlite::memory:").await.unwrap(); + execute_sqlite_setup(&pool, SETUP).await.unwrap(); + let schema = sql_cov::schema::SchemaCatalog::load_sqlite(&pool).await.unwrap(); + let analysis = analyze_sql(name, sql, DialectName::Sqlite, Some(&schema)).unwrap(); + let parameters = parameters + .iter() + .map(|value| value.to_string()) + .collect::>(); + let coverage = cover_sqlite(&pool, &analysis, ¶meters) + .await + .unwrap_or_else(|error| panic!("{name} did not execute: {error:#}")); + assert_eq!(coverage.statements.len(), 1, "{name}"); + assert_eq!(coverage.statements[0].hit_count, 1, "{name}"); + } +} diff --git a/gems/sql-cov/tests/live_databases.rs b/gems/sql-cov/tests/live_databases.rs new file mode 100644 index 000000000..f8aedef40 --- /dev/null +++ b/gems/sql-cov/tests/live_databases.rs @@ -0,0 +1,88 @@ +use sql_cov::driver::{mysql_pool, postgres_pool}; +use sql_cov::schema::SchemaCatalog; +use sql_cov::{ + analyze_hazards, analyze_sql, cover_mysql, cover_postgres, execute_mysql_setup, + execute_postgres_setup, DialectName, HazardKind, +}; + +#[tokio::test] +async fn postgres_live_schema_coverage_and_any_all_hazard() { + let Ok(url) = std::env::var("SQL_COV_POSTGRES_URL") else { + return; + }; + let pool = postgres_pool(&url).await.unwrap(); + execute_postgres_setup( + &pool, + "DROP TABLE IF EXISTS sql_cov_rhs; DROP TABLE IF EXISTS sql_cov_users;\ + CREATE TABLE sql_cov_users(name TEXT NOT NULL, bonus BIGINT, age BIGINT NOT NULL);\ + CREATE TABLE sql_cov_rhs(value BIGINT);\ + INSERT INTO sql_cov_users VALUES ('a', 5, 30), ('b', 0, 10), ('c', NULL, 20);\ + INSERT INTO sql_cov_rhs VALUES (30), (NULL);", + ) + .await + .unwrap(); + let schema = SchemaCatalog::load_postgres(&pool).await.unwrap(); + assert!(schema.column("sql_cov_users", "bonus").unwrap().nullable); + assert!(!schema.column("sql_cov_users", "age").unwrap().nullable); + + let query = "SELECT name FROM sql_cov_users WHERE bonus <> 0 AND age > 18"; + let analysis = analyze_sql("postgres.sql", query, DialectName::Postgres, Some(&schema)).unwrap(); + let coverage = cover_postgres(&pool, &analysis, &[]).await.unwrap(); + assert_eq!(coverage.statements[0].hit_count, 1); + assert!(coverage + .metrics + .iter() + .all( + |metric| metric.hit_true_count + metric.hit_false_count + metric.hit_unknown_count == 3 + )); + + let quantified = + "SELECT name FROM sql_cov_users WHERE age = ALL (SELECT value FROM sql_cov_rhs)"; + let report = + analyze_hazards("postgres.sql", quantified, DialectName::Postgres, &schema).unwrap(); + assert!(report + .findings + .iter() + .any(|finding| finding.kind == HazardKind::NullableAnyAll)); +} + +#[tokio::test] +async fn mysql_or_mariadb_live_schema_coverage_and_any_all_hazard() { + let url = std::env::var("SQL_COV_MYSQL_URL").or_else(|_| std::env::var("SQL_COV_MARIADB_URL")); + let Ok(url) = url else { return }; + let pool = mysql_pool(&url).await.unwrap(); + execute_mysql_setup( + &pool, + "DROP TABLE IF EXISTS sql_cov_rhs; DROP TABLE IF EXISTS sql_cov_users;\ + CREATE TABLE sql_cov_users(name TEXT NOT NULL, bonus BIGINT NULL, age BIGINT NOT NULL);\ + CREATE TABLE sql_cov_rhs(value BIGINT NULL);\ + INSERT INTO sql_cov_users VALUES ('a', 5, 30), ('b', 0, 10), ('c', NULL, 20);\ + INSERT INTO sql_cov_rhs VALUES (30), (NULL);", + ) + .await + .unwrap(); + let schema = SchemaCatalog::load_mysql(&pool).await.unwrap(); + assert!(schema.column("sql_cov_users", "bonus").unwrap().nullable); + assert!(!schema.column("sql_cov_users", "age").unwrap().nullable); + + let query = "SELECT name FROM sql_cov_users WHERE bonus <> 0 AND age > ?"; + let analysis = analyze_sql("mysql.sql", query, DialectName::Mysql, Some(&schema)).unwrap(); + let coverage = cover_mysql(&pool, &analysis, &["int:18".to_string()]) + .await + .unwrap(); + assert_eq!(coverage.statements[0].hit_count, 1); + assert!(coverage + .metrics + .iter() + .all( + |metric| metric.hit_true_count + metric.hit_false_count + metric.hit_unknown_count == 3 + )); + + let quantified = + "SELECT name FROM sql_cov_users WHERE age = ALL (SELECT value FROM sql_cov_rhs)"; + let report = analyze_hazards("mysql.sql", quantified, DialectName::Mysql, &schema).unwrap(); + assert!(report + .findings + .iter() + .any(|finding| finding.kind == HazardKind::NullableAnyAll)); +} diff --git a/gems/sql-cov/tests/quantified_comparisons.rs b/gems/sql-cov/tests/quantified_comparisons.rs new file mode 100644 index 000000000..7e9060b18 --- /dev/null +++ b/gems/sql-cov/tests/quantified_comparisons.rs @@ -0,0 +1,175 @@ +use sql_cov::driver::sqlite_pool; +use sql_cov::schema::SchemaCatalog; +use sql_cov::{analyze_hazards, execute_sqlite_setup, DialectName, HazardKind}; + +const SCHEMA: &str = include_str!("fixtures/hazards.sql"); +const OPERATORS: [&str; 6] = ["=", "<>", "<", "<=", ">", ">="]; +const QUANTIFIERS: [&str; 2] = ["ANY", "ALL"]; + +async fn schema() -> SchemaCatalog { + let pool = sqlite_pool("sqlite::memory:").await.unwrap(); + execute_sqlite_setup(&pool, SCHEMA).await.unwrap(); + SchemaCatalog::load_sqlite(&pool).await.unwrap() +} + +fn has_quantified_hazard(sql: &str, schema: &SchemaCatalog) -> bool { + analyze_hazards("quantified.sql", sql, DialectName::Postgres, schema) + .unwrap() + .findings + .iter() + .any(|finding| finding.kind == HazardKind::NullableAnyAll) +} + +#[tokio::test] +async fn postgres_any_all_covers_every_strict_comparison_operator_and_null_source() { + let schema = schema().await; + for quantifier in QUANTIFIERS { + for operator in OPERATORS { + let nullable_left = + format!("SELECT name FROM users WHERE bonus {operator} {quantifier} (ARRAY[1, 2])"); + assert!( + has_quantified_hazard(&nullable_left, &schema), + "missed nullable left operand: {nullable_left}" + ); + + let nullable_element = format!( + "SELECT name FROM users WHERE age {operator} {quantifier} (ARRAY[1, NULL])" + ); + assert!( + has_quantified_hazard(&nullable_element, &schema), + "missed nullable array element: {nullable_element}" + ); + + let non_null = + format!("SELECT name FROM users WHERE age {operator} {quantifier} (ARRAY[1, 2])"); + assert!( + !has_quantified_hazard(&non_null, &schema), + "false positive for non-null operands: {non_null}" + ); + } + } +} + +#[tokio::test] +async fn postgres_any_all_resolves_nullable_and_required_subquery_outputs() { + let schema = schema().await; + for quantifier in QUANTIFIERS { + for operator in OPERATORS { + let nullable_output = format!( + "SELECT name FROM users WHERE age {operator} {quantifier} (SELECT bonus FROM users)" + ); + assert!( + has_quantified_hazard(&nullable_output, &schema), + "missed nullable subquery output: {nullable_output}" + ); + + let required_output = format!( + "SELECT name FROM users WHERE age {operator} {quantifier} (SELECT age FROM users)" + ); + assert!( + !has_quantified_hazard(&required_output, &schema), + "false positive for required subquery output: {required_output}" + ); + } + } +} + +#[tokio::test] +async fn postgres_some_alias_and_null_array_are_covered() { + let schema = schema().await; + assert!(has_quantified_hazard( + "SELECT name FROM users WHERE age = SOME (ARRAY[1, NULL])", + &schema + )); + assert!(has_quantified_hazard( + "SELECT name FROM users WHERE age = ANY (NULL)", + &schema + )); + assert!(has_quantified_hazard( + "SELECT name FROM users WHERE age = ALL (NULL)", + &schema + )); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Truth { + True, + False, + Unknown, +} + +fn compare(operator: &str, left: Option, right: Option) -> Truth { + let (Some(left), Some(right)) = (left, right) else { + return Truth::Unknown; + }; + let value = match operator { + "=" => left == right, + "<>" => left != right, + "<" => left < right, + "<=" => left <= right, + ">" => left > right, + ">=" => left >= right, + _ => unreachable!(), + }; + if value { + Truth::True + } else { + Truth::False + } +} + +fn quantified(quantifier: &str, operator: &str, left: Option, right: &[Option]) -> Truth { + let comparisons = right + .iter() + .map(|right| compare(operator, left, *right)) + .collect::>(); + match quantifier { + "ANY" if comparisons.contains(&Truth::True) => Truth::True, + "ANY" if comparisons.contains(&Truth::Unknown) => Truth::Unknown, + "ANY" => Truth::False, + "ALL" if comparisons.contains(&Truth::False) => Truth::False, + "ALL" if comparisons.contains(&Truth::Unknown) => Truth::Unknown, + "ALL" => Truth::True, + _ => unreachable!(), + } +} + +#[test] +fn strict_any_all_truth_table_exhausts_empty_null_mixed_and_decisive_sets() { + let left_values = [None, Some(0), Some(1)]; + let right_sets = [ + vec![], + vec![None], + vec![Some(0)], + vec![Some(1)], + vec![Some(0), None], + vec![Some(1), None], + vec![Some(0), Some(1)], + vec![Some(0), Some(1), None], + ]; + let mut cases = 0; + for quantifier in QUANTIFIERS { + for operator in OPERATORS { + for left in left_values { + for right in &right_sets { + let result = quantified(quantifier, operator, left, right); + if right.is_empty() { + assert_eq!( + result, + if quantifier == "ANY" { + Truth::False + } else { + Truth::True + } + ); + } + if right.iter().all(Option::is_none) && !right.is_empty() { + assert_eq!(result, Truth::Unknown); + } + cases += 1; + } + } + } + } + assert_eq!(cases, 288); +} diff --git a/gems/sql-cov/tests/schema_driver.rs b/gems/sql-cov/tests/schema_driver.rs new file mode 100644 index 000000000..743c27347 --- /dev/null +++ b/gems/sql-cov/tests/schema_driver.rs @@ -0,0 +1,101 @@ +use sql_cov::driver::{sqlite_pool, execute_sqlite_setup, ParameterValue}; +use sql_cov::schema::{normalize_identifier, SchemaCatalog}; +use sql_cov::DialectName; + +#[test] +fn test_normalize_identifier() { + assert_eq!(normalize_identifier(" `USER` "), "user"); + assert_eq!(normalize_identifier(" [Age] "), "age"); + assert_eq!(normalize_identifier(" \"Bonus\" "), "bonus"); + assert_eq!(normalize_identifier("Normal"), "normal"); +} + +#[test] +fn test_dialect_name_parse_and_str() { + assert_eq!(DialectName::parse("sqlite").unwrap(), DialectName::Sqlite); + assert_eq!(DialectName::parse("sqlite3").unwrap(), DialectName::Sqlite); + assert_eq!(DialectName::parse("postgres").unwrap(), DialectName::Postgres); + assert_eq!(DialectName::parse("postgresql").unwrap(), DialectName::Postgres); + assert_eq!(DialectName::parse("pg").unwrap(), DialectName::Postgres); + assert_eq!(DialectName::parse("mysql").unwrap(), DialectName::Mysql); + assert_eq!(DialectName::parse("mariadb").unwrap(), DialectName::Mysql); + assert_eq!(DialectName::parse("maria").unwrap(), DialectName::Mysql); + assert!(DialectName::parse("invalid").is_err()); + + assert_eq!(DialectName::Sqlite.as_str(), "sqlite"); + assert_eq!(DialectName::Postgres.as_str(), "postgres"); + assert_eq!(DialectName::Mysql.as_str(), "mysql"); +} + +#[test] +fn test_parameter_value_parse() { + assert_eq!(ParameterValue::parse("null").unwrap(), ParameterValue::NullText); + assert_eq!(ParameterValue::parse("null:text").unwrap(), ParameterValue::NullText); + assert_eq!(ParameterValue::parse("null:int").unwrap(), ParameterValue::NullInteger); + assert_eq!(ParameterValue::parse("null:integer").unwrap(), ParameterValue::NullInteger); + assert_eq!(ParameterValue::parse("null:float").unwrap(), ParameterValue::NullFloat); + assert_eq!(ParameterValue::parse("null:bool").unwrap(), ParameterValue::NullBoolean); + assert_eq!(ParameterValue::parse("null:boolean").unwrap(), ParameterValue::NullBoolean); + + assert_eq!(ParameterValue::parse("int:123").unwrap(), ParameterValue::Integer(123)); + assert!(ParameterValue::parse("int:abc").is_err()); + + assert_eq!(ParameterValue::parse("float:12.34").unwrap(), ParameterValue::Float(12.34)); + assert!(ParameterValue::parse("float:abc").is_err()); + + assert_eq!(ParameterValue::parse("bool:true").unwrap(), ParameterValue::Boolean(true)); + assert_eq!(ParameterValue::parse("bool:false").unwrap(), ParameterValue::Boolean(false)); + assert!(ParameterValue::parse("bool:abc").is_err()); + + assert_eq!(ParameterValue::parse("text:hello").unwrap(), ParameterValue::Text("hello".to_string())); + assert_eq!(ParameterValue::parse("hello").unwrap(), ParameterValue::Text("hello".to_string())); +} + +#[tokio::test] +async fn test_sqlite_schema_catalog_loading() { + let pool = sqlite_pool("sqlite::memory:").await.unwrap(); + execute_sqlite_setup( + &pool, + "CREATE TABLE test_table (\ + id INTEGER PRIMARY KEY,\ + name TEXT NOT NULL,\ + age INTEGER,\ + salary REAL NOT NULL\ + );\ + CREATE VIEW test_view AS SELECT id, name FROM test_table;", + ) + .await + .unwrap(); + + let catalog = SchemaCatalog::load_sqlite(&pool).await.unwrap(); + + // Verify table structure + let table = catalog.tables.get("test_table").unwrap(); + assert_eq!(table.name, "test_table"); + + // Verify columns + let id_col = catalog.column("test_table", "id").unwrap(); + assert_eq!(id_col.name, "id"); + assert!(!id_col.nullable); + assert!(id_col.primary_key); + + let name_col = catalog.column("test_table", "name").unwrap(); + assert_eq!(name_col.name, "name"); + assert!(!name_col.nullable); + assert!(!name_col.primary_key); + + let age_col = catalog.column("test_table", "age").unwrap(); + assert_eq!(age_col.name, "age"); + assert!(age_col.nullable); + assert!(!age_col.primary_key); + + let salary_col = catalog.column("test_table", "salary").unwrap(); + assert_eq!(salary_col.name, "salary"); + assert!(!salary_col.nullable); + assert!(!salary_col.primary_key); + + // Verify view loading + let view = catalog.tables.get("test_view").unwrap(); + assert_eq!(view.name, "test_view"); + assert!(catalog.column("test_view", "name").is_some()); +} diff --git a/gems/sql-cov/tests/uncovered_paths.rs b/gems/sql-cov/tests/uncovered_paths.rs new file mode 100644 index 000000000..032b1cc6f --- /dev/null +++ b/gems/sql-cov/tests/uncovered_paths.rs @@ -0,0 +1,378 @@ +use sql_cov::driver::{postgres_pool, mysql_pool, sqlite_pool, ParameterValue}; +use sql_cov::schema::{normalize_identifier, SchemaCatalog}; +use sql_cov::{ + cover_postgres, cover_mysql, DialectName, analyze_sql, +}; +use sql_cov::model::SourceFileCoverage; +use sql_cov::reporter; + +#[test] +fn test_parameter_value_parsing() { + assert_eq!(ParameterValue::parse("null").unwrap(), ParameterValue::NullText); + assert_eq!(ParameterValue::parse("null:text").unwrap(), ParameterValue::NullText); + assert_eq!(ParameterValue::parse("null:int").unwrap(), ParameterValue::NullInteger); + assert_eq!(ParameterValue::parse("null:integer").unwrap(), ParameterValue::NullInteger); + assert_eq!(ParameterValue::parse("null:float").unwrap(), ParameterValue::NullFloat); + assert_eq!(ParameterValue::parse("null:bool").unwrap(), ParameterValue::NullBoolean); + assert_eq!(ParameterValue::parse("null:boolean").unwrap(), ParameterValue::NullBoolean); + + assert_eq!(ParameterValue::parse("int:42").unwrap(), ParameterValue::Integer(42)); + assert_eq!(ParameterValue::parse("float:1.5").unwrap(), ParameterValue::Float(1.5)); + assert_eq!(ParameterValue::parse("bool:true").unwrap(), ParameterValue::Boolean(true)); + assert_eq!(ParameterValue::parse("bool:false").unwrap(), ParameterValue::Boolean(false)); + + assert_eq!(ParameterValue::parse("text:foo").unwrap(), ParameterValue::Text("foo".to_string())); + assert_eq!(ParameterValue::parse("foo").unwrap(), ParameterValue::Text("foo".to_string())); + + // Invalid parameters + assert!(ParameterValue::parse("int:invalid").is_err()); + assert!(ParameterValue::parse("float:invalid").is_err()); + assert!(ParameterValue::parse("bool:invalid").is_err()); +} + +#[tokio::test] +async fn test_pool_connection_failures() { + // Passing invalid URLs to verify connection error handling + assert!(postgres_pool("postgres://invalid-host-name-12345.com").await.is_err()); + assert!(mysql_pool("mysql://invalid-host-name-12345.com").await.is_err()); + assert!(sqlite_pool("sqlite://invalid-sqlite-path/non-existent-dir/db.sqlite").await.is_err()); +} + +#[tokio::test] +async fn test_postgres_mysql_coverage_errors() { + let pg_query = "SELECT name FROM users WHERE id = $1 AND name = $2"; + let pg_analysis = analyze_sql("test.sql", pg_query, DialectName::Postgres, None).unwrap(); + let mysql_query = "SELECT name FROM users WHERE id = ? AND name = ?"; + let mysql_analysis = analyze_sql("test.sql", mysql_query, DialectName::Mysql, None).unwrap(); + let params = vec![ + "int:1".to_string(), + "text:foo".to_string(), + "float:1.5".to_string(), + "bool:true".to_string(), + "null:text".to_string(), + "null:int".to_string(), + "null:float".to_string(), + "null:bool".to_string(), + ]; + + let pg_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect_lazy("postgres://127.0.0.1:9999/dummy") + .unwrap(); + assert!(cover_postgres(&pg_pool, &pg_analysis, ¶ms).await.is_err()); + + let mysql_pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect_lazy("mysql://127.0.0.1:9999/dummy") + .unwrap(); + assert!(cover_mysql(&mysql_pool, &mysql_analysis, ¶ms).await.is_err()); +} + +#[tokio::test] +async fn test_schema_catalog_failures() { + let pg_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect_lazy("postgres://127.0.0.1:9999/dummy") + .unwrap(); + assert!(SchemaCatalog::load_postgres(&pg_pool).await.is_err()); + + let mysql_pool = sqlx::mysql::MySqlPoolOptions::new() + .max_connections(1) + .acquire_timeout(std::time::Duration::from_secs(1)) + .connect_lazy("mysql://127.0.0.1:9999/dummy") + .unwrap(); + assert!(SchemaCatalog::load_mysql(&mysql_pool).await.is_err()); +} + +#[test] +fn test_schema_catalog_helpers() { + let catalog = SchemaCatalog::default(); + assert!(catalog.column("users", "name").is_none()); + + assert_eq!(normalize_identifier("\"users\""), "users"); + assert_eq!(normalize_identifier("`users`"), "users"); + assert_eq!(normalize_identifier("[users]"), "users"); + assert_eq!(normalize_identifier(" Users "), "users"); +} + +#[test] +fn test_dialect_name_parse() { + assert_eq!(DialectName::parse("sqlite").unwrap(), DialectName::Sqlite); + assert_eq!(DialectName::parse("sqlite3").unwrap(), DialectName::Sqlite); + assert_eq!(DialectName::parse("postgres").unwrap(), DialectName::Postgres); + assert_eq!(DialectName::parse("postgresql").unwrap(), DialectName::Postgres); + assert_eq!(DialectName::parse("pg").unwrap(), DialectName::Postgres); + assert_eq!(DialectName::parse("mysql").unwrap(), DialectName::Mysql); + assert_eq!(DialectName::parse("mariadb").unwrap(), DialectName::Mysql); + assert_eq!(DialectName::parse("maria").unwrap(), DialectName::Mysql); + + assert!(DialectName::parse("invalid-dialect").is_err()); + assert_eq!(DialectName::Sqlite.as_str(), "sqlite"); + assert_eq!(DialectName::Postgres.as_str(), "postgres"); + assert_eq!(DialectName::Mysql.as_str(), "mysql"); +} + +#[test] +fn test_reporter_invalid_formats() { + use sql_cov::model::{ExpressionSpan, CoverageMetric}; + use sql_cov::hazard::{HazardReport, HazardFinding}; + + let dummy_span = ExpressionSpan { + id: 1, + start_offset: 0, + end_offset: 8, + start_line: 1, + start_column: 1, + end_line: 1, + end_column: 9, + raw_expression: "SELECT 1".to_string(), + normalized_expression: "SELECT 1".to_string(), + context: "SELECT".to_string(), + nullable: true, + parameter_indices: vec![], + }; + + let coverage = SourceFileCoverage { + format: "sql-cov/json".to_string(), + file_path: "test.sql".to_string(), + dialect: "sqlite".to_string(), + raw_source: "SELECT 1".to_string(), + metrics: vec![ + CoverageMetric { + span: dummy_span.clone(), + measurable: false, + hit_true_count: 0, + hit_false_count: 0, + hit_unknown_count: 0, + }, + CoverageMetric { + span: dummy_span.clone(), + measurable: true, + hit_true_count: 0, + hit_false_count: 0, + hit_unknown_count: 0, + }, + CoverageMetric { + span: dummy_span.clone(), + measurable: true, + hit_true_count: 1, + hit_false_count: 1, + hit_unknown_count: 1, + }, + CoverageMetric { + span: dummy_span.clone(), + measurable: true, + hit_true_count: 1, + hit_false_count: 0, + hit_unknown_count: 0, + }, + ], + statements: vec![], + unsupported: vec!["Some unsupported feature".to_string()], + }; + assert!(reporter::json(&coverage).is_ok()); + assert!(!reporter::lcov(&coverage).is_empty()); + assert!(!reporter::html(&coverage).is_empty()); + + // Hazard reports and SARIF + let dummy_hazard_span = sql_cov::hazard::HazardSpan { + start_offset: 0, + end_offset: 8, + start_line: 1, + start_column: 1, + end_line: 1, + end_column: 9, + raw_expression: "SELECT 1".to_string(), + }; + + let hazard_report = HazardReport { + format: "sql-cov/hazard/json".to_string(), + file_path: "test.sql".to_string(), + dialect: "sqlite".to_string(), + findings: vec![ + HazardFinding { + id: "test-id".to_string(), + rule_id: "SQL001".to_string(), + kind: sql_cov::HazardKind::NullableAnyAll, + message: "Test finding".to_string(), + evidence: vec!["test evidence".to_string()], + recommendation: "Test recommendation".to_string(), + span: dummy_hazard_span, + } + ], + unresolved_schema_facts: vec!["unresolved fact".to_string()], + }; + assert!(sql_cov::sarif::hazard_json(&hazard_report).is_ok()); + assert!(sql_cov::sarif::hazard_sarif(&hazard_report, None).is_ok()); +} + +#[test] +fn test_more_hazards_and_nullability() { + use sql_cov::hazard::analyze_hazards; + use sql_cov::schema::SchemaCatalog; + + let schema = SchemaCatalog::default(); + + // 1. HAVING clause + let query_having = "SELECT name FROM users GROUP BY name HAVING bonus <> 0"; + analyze_hazards("test.sql", query_having, DialectName::Sqlite, &schema).unwrap(); + + // 2. Unresolved column + let query_unresolved = "SELECT name FROM users WHERE non_existent_column <> 0"; + analyze_hazards("test.sql", query_unresolved, DialectName::Sqlite, &schema).unwrap(); + + // 3. NOT IN UNNEST + let query_unnest = "SELECT name FROM users WHERE age NOT IN (UNNEST(non_existent_column))"; + analyze_hazards("test.sql", query_unnest, DialectName::Sqlite, &schema).unwrap(); + + // 4. Different Join variants + let join_queries = vec![ + ("SELECT * FROM a LEFT JOIN b USING (id)", DialectName::Sqlite), + ("SELECT * FROM a FULL OUTER JOIN b ON a.id = b.id", DialectName::Postgres), + ("SELECT * FROM a CROSS JOIN b", DialectName::Sqlite), + ("SELECT * FROM a INNER JOIN b ON a.id = b.id", DialectName::Sqlite), + ("SELECT * FROM a STRAIGHT_JOIN b ON a.id = b.id", DialectName::Mysql), + ]; + for (q, dialect) in join_queries { + analyze_sql("test.sql", q, dialect, Some(&schema)).unwrap(); + } + + // Semi and Anti joins (try to parse, but don't panic if sqlparser dialect doesn't support them) + let _ = analyze_sql("test.sql", "SELECT * FROM a LEFT SEMI JOIN b ON a.id = b.id", DialectName::Mysql, Some(&schema)); + let _ = analyze_sql("test.sql", "SELECT * FROM a ANTI JOIN b ON a.id = b.id", DialectName::Mysql, Some(&schema)); + + // 5. COALESCE logic and advanced expressions + let coalesce_queries = vec![ + "SELECT COALESCE(bonus)", + "SELECT COALESCE(bonus, 0)", + "SELECT COALESCE(non_existent_column, 0)", + "SELECT name FROM users WHERE age IS TRUE AND age IS NOT TRUE AND age IS FALSE AND age IS NOT FALSE AND age IS UNKNOWN AND age IS NOT UNKNOWN", + "SELECT name FROM users WHERE age IS DISTINCT FROM bonus AND age IS NOT DISTINCT FROM bonus", + "SELECT name FROM users WHERE (CASE WHEN age > 18 THEN 1 ELSE 0 END) = 1", + "SELECT name FROM users WHERE (CASE age WHEN 18 THEN 1 ELSE 0 END) = 1", + ]; + for q in coalesce_queries { + analyze_sql("test.sql", q, DialectName::Sqlite, Some(&schema)).unwrap(); + } +} + +#[test] +fn test_sqlfluff_sarif_merging_and_tiering() { + use sql_cov::sarif; + + use sql_cov::hazard::analyze_hazards; + use sql_cov::schema::SchemaCatalog; + + let schema = SchemaCatalog { + tables: std::collections::HashMap::new(), + }; + let report = analyze_hazards( + "test.sql", + "SELECT * FROM users WHERE age IS NULL", + DialectName::Sqlite, + &schema, + ) + .unwrap(); + + // Mock a SQLFluff SARIF report + let mock_sqlfluff_sarif = r#"{ + "runs": [ + { + "tool": { + "driver": { + "name": "SQLFluff", + "rules": [ + { + "id": "AM05", + "name": "ambiguous.join", + "properties": {} + }, + { + "id": "CV02", + "name": "convention.coalesce", + "properties": {} + }, + { + "id": "LT01", + "name": "layout.spacing", + "properties": {} + } + ] + } + }, + "results": [ + { + "ruleId": "AM05", + "message": { "text": "Implicit join used." }, + "properties": {} + }, + { + "ruleId": "CV02", + "message": { "text": "Use COALESCE instead." }, + "properties": {} + }, + { + "ruleId": "LT01", + "message": { "text": "Incorrect spacing." }, + "properties": {} + } + ] + } + ] + }"#; + + let sarif_output = sarif::hazard_sarif(&report, Some(mock_sqlfluff_sarif)).unwrap(); + + // Verify that the rules and results are successfully merged + assert!(sarif_output.contains("AM05")); + assert!(sarif_output.contains("CV02")); + assert!(sarif_output.contains("LT01")); + + // Verify that the correct tiers are assigned + assert!(sarif_output.contains(r#""tier": "T1""#)); // SQL-cov findings and AM05 + assert!(sarif_output.contains(r#""tier": "T2""#)); // CV02 + assert!(sarif_output.contains(r#""tier": "T3""#)); // LT01 +} + +#[test] +fn test_generate_check_logic() { + use sql_cov::hazard::analyze_hazards; + use sql_cov::schema::SchemaCatalog; + + let mut schema = SchemaCatalog { + tables: std::collections::HashMap::new(), + }; + schema.insert_column("users".to_string(), "age".to_string(), true, false); + + let report = analyze_hazards( + "test.sql", + "SELECT * FROM users WHERE age != 18", + DialectName::Sqlite, + &schema, + ) + .unwrap(); + + assert_eq!(report.findings.len(), 1); + let finding = &report.findings[0]; + + // Check that we can extract the column from the evidence + let mut matched_targets = Vec::new(); + for ev in &finding.evidence { + if ev.starts_with("schema declares ") && ev.ends_with(" nullable") { + let inner = &ev["schema declares ".len()..(ev.len() - " nullable".len())]; + if let Some(dot_idx) = inner.find('.') { + let table = &inner[..dot_idx]; + let col = &inner[dot_idx + 1..]; + matched_targets.push((table.to_string(), col.to_string())); + } + } + } + + assert_eq!(matched_targets.len(), 1); + assert_eq!(matched_targets[0].0, "users"); + assert_eq!(matched_targets[0].1, "age"); +} + diff --git a/gems/zig-mutants/src/main.zig b/gems/zig-mutants/src/main.zig index 8cf9c658a..bb0b8d614 100644 --- a/gems/zig-mutants/src/main.zig +++ b/gems/zig-mutants/src/main.zig @@ -735,13 +735,16 @@ fn copyWorkspace(allocator: Allocator, io: std.Io, root: []const u8, out_dir: [] \\mkdir -p "$1" && \\tar \ \\ --exclude=.git \ - \\ --exclude=.zig-cache \ - \\ --exclude=zig-cache \ - \\ --exclude=zig/.clear-cache \ - \\ --exclude=zig/zig-out \ + \\ --exclude="*/.zig-cache" \ + \\ --exclude="*/zig-cache" \ + \\ --exclude="*/.clear-cache" \ + \\ --exclude="*/zig-out" \ + \\ --exclude="*/target" \ + \\ --exclude="*/node_modules" \ + \\ --exclude="*/tmp" \ \\ -C "$0" -cf - . | tar -C "$1" -xf - ; - const argv = [_][]const u8{ "bash", "-lc", script, root, out_dir }; + const argv = [_][]const u8{ "bash", "-c", script, root, out_dir }; const result = try std.process.run(allocator, io, .{ .argv = &argv, .stdout_limit = .limited(1024 * 1024), diff --git a/gems/zig-mutants/subjects.json b/gems/zig-mutants/subjects.json index 6aa7fb332..5e8636452 100644 --- a/gems/zig-mutants/subjects.json +++ b/gems/zig-mutants/subjects.json @@ -39,6 +39,11 @@ "source": "zig/runtime/control-plane.zig", "test_command": "cd zig && zig build test-tsan -Dtest-file=control-plane-test.zig -j1 && zig build test-hammer -Dtest-file=control-plane-hammer-test.zig -j1", "timeout_seconds": 60 + }, + { + "source": "zig/runtime/scheduler.zig", + "test_command": "cd zig && zig build test-tsan -Dtest-file=scheduler-test.zig -j1 && zig build test-tsan -Dtest-file=scheduler-direct-test.zig -j1 && zig build test-tsan -Dtest-file=spsc-scheduler-test.zig -j1 && zig build test-hammer -Dtest-file=scheduler-primitives-hammer-test.zig -j1 && zig build test-loom-vopr -Dtest-file=scheduler-timeout-vopr-test.zig -j1", + "timeout_seconds": 300 } ] } diff --git a/package-lock.json b/package-lock.json index 717ce042c..d9c595cbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "litedb", + "name": "easy-vm", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/tools/fuzz/mutants/patches/allow_with_alias_return.patch b/tools/fuzz/mutants/patches/allow_with_alias_return.patch index bf53bd553..de6d0c1b4 100644 --- a/tools/fuzz/mutants/patches/allow_with_alias_return.patch +++ b/tools/fuzz/mutants/patches/allow_with_alias_return.patch @@ -1,6 +1,6 @@ -diff --git a/src/annotator/domains/errors.rb b/src/annotator/domains/errors.rb ---- a/src/annotator/domains/errors.rb -+++ b/src/annotator/domains/errors.rb +diff --git a/compiler/ruby/annotator/domains/errors.rb b/compiler/ruby/annotator/domains/errors.rb +--- a/compiler/ruby/annotator/domains/errors.rb ++++ b/compiler/ruby/annotator/domains/errors.rb @@ -411,7 +411,7 @@ # SymbolEntry#non_escaping flag is set on every WITH alias by # declare_capability_scope!; it's the same flag ensure_owned_value! diff --git a/tools/fuzz/mutants/patches/bg_lifetime_all_captures_independent.patch b/tools/fuzz/mutants/patches/bg_lifetime_all_captures_independent.patch index 14b2935df..dc65fc835 100644 --- a/tools/fuzz/mutants/patches/bg_lifetime_all_captures_independent.patch +++ b/tools/fuzz/mutants/patches/bg_lifetime_all_captures_independent.patch @@ -1,7 +1,7 @@ -diff --git a/src/annotator/domains/lifetimes.rb b/src/annotator/domains/lifetimes.rb +diff --git a/compiler/ruby/annotator/domains/lifetimes.rb b/compiler/ruby/annotator/domains/lifetimes.rb index 000000000..000000000 100644 ---- a/src/annotator/domains/lifetimes.rb -+++ b/src/annotator/domains/lifetimes.rb +--- a/compiler/ruby/annotator/domains/lifetimes.rb ++++ b/compiler/ruby/annotator/domains/lifetimes.rb @@ -982,9 +982,7 @@ class SemanticAnnotator def bg_lifetime_sources(analysis) T.bind(self, SemanticAnnotator) diff --git a/tools/fuzz/mutants/patches/capture_promise_handle_by_value.patch b/tools/fuzz/mutants/patches/capture_promise_handle_by_value.patch index 13539b150..67ebc5bbf 100644 --- a/tools/fuzz/mutants/patches/capture_promise_handle_by_value.patch +++ b/tools/fuzz/mutants/patches/capture_promise_handle_by_value.patch @@ -1,7 +1,7 @@ -diff --git a/src/semantic/capture_strategy.rb b/src/semantic/capture_strategy.rb +diff --git a/compiler/ruby/semantic/capture_strategy.rb b/compiler/ruby/semantic/capture_strategy.rb index 000000000..000000000 100644 ---- a/src/semantic/capture_strategy.rb -+++ b/src/semantic/capture_strategy.rb +--- a/compiler/ruby/semantic/capture_strategy.rb ++++ b/compiler/ruby/semantic/capture_strategy.rb @@ -211,7 +211,7 @@ module CaptureStrategy # 3. A plain promise handle (~T) is an owned affine capability. Capturing # it into a fiber transfers the one right to NEXT it. Shared promises diff --git a/tools/fuzz/mutants/patches/cleanup_required_finalizer.patch b/tools/fuzz/mutants/patches/cleanup_required_finalizer.patch index 45c086877..162763146 100644 --- a/tools/fuzz/mutants/patches/cleanup_required_finalizer.patch +++ b/tools/fuzz/mutants/patches/cleanup_required_finalizer.patch @@ -1,6 +1,6 @@ -diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb ---- a/src/mir/mir_checker.rb -+++ b/src/mir/mir_checker.rb +diff --git a/compiler/ruby/mir/mir_checker.rb b/compiler/ruby/mir/mir_checker.rb +--- a/compiler/ruby/mir/mir_checker.rb ++++ b/compiler/ruby/mir/mir_checker.rb @@ -433,7 +433,6 @@ class MIRChecker verify_allocating_lets_marked!(nodes, allocs) verify_aggregate_owned_children!(fn_def.body, allocs) diff --git a/tools/fuzz/mutants/patches/concurrency_skip_hold_yield.patch b/tools/fuzz/mutants/patches/concurrency_skip_hold_yield.patch index bf9178f10..9b32488ed 100644 --- a/tools/fuzz/mutants/patches/concurrency_skip_hold_yield.patch +++ b/tools/fuzz/mutants/patches/concurrency_skip_hold_yield.patch @@ -1,6 +1,6 @@ -diff --git a/src/semantic/concurrency_checks.rb b/src/semantic/concurrency_checks.rb ---- a/src/semantic/concurrency_checks.rb -+++ b/src/semantic/concurrency_checks.rb +diff --git a/compiler/ruby/semantic/concurrency_checks.rb b/compiler/ruby/semantic/concurrency_checks.rb +--- a/compiler/ruby/semantic/concurrency_checks.rb ++++ b/compiler/ruby/semantic/concurrency_checks.rb @@ -48,7 +48,7 @@ module ConcurrencyChecks summary = body_summaries.fetch(fn.name) with_blocks = summary.with_blocks diff --git a/tools/fuzz/mutants/patches/escape_identifier_heap_noop.patch b/tools/fuzz/mutants/patches/escape_identifier_heap_noop.patch index 05de6ba53..52cacfe6f 100644 --- a/tools/fuzz/mutants/patches/escape_identifier_heap_noop.patch +++ b/tools/fuzz/mutants/patches/escape_identifier_heap_noop.patch @@ -1,6 +1,6 @@ -diff --git a/src/semantic/escape_analysis.rb b/src/semantic/escape_analysis.rb ---- a/src/semantic/escape_analysis.rb -+++ b/src/semantic/escape_analysis.rb +diff --git a/compiler/ruby/semantic/escape_analysis.rb b/compiler/ruby/semantic/escape_analysis.rb +--- a/compiler/ruby/semantic/escape_analysis.rb ++++ b/compiler/ruby/semantic/escape_analysis.rb @@ -565,6 +565,7 @@ sig { params(expr: NodeValue).returns(T::Boolean) } diff --git a/tools/fuzz/mutants/patches/escape_struct_field_walker.patch b/tools/fuzz/mutants/patches/escape_struct_field_walker.patch index ebf6e6844..ca24362a0 100644 --- a/tools/fuzz/mutants/patches/escape_struct_field_walker.patch +++ b/tools/fuzz/mutants/patches/escape_struct_field_walker.patch @@ -1,6 +1,6 @@ -diff --git a/src/semantic/escape_analysis.rb b/src/semantic/escape_analysis.rb ---- a/src/semantic/escape_analysis.rb -+++ b/src/semantic/escape_analysis.rb +diff --git a/compiler/ruby/semantic/escape_analysis.rb b/compiler/ruby/semantic/escape_analysis.rb +--- a/compiler/ruby/semantic/escape_analysis.rb ++++ b/compiler/ruby/semantic/escape_analysis.rb @@ -665,5 +665,6 @@ module EscapeAnalysis sig { params(receiver: AST::Node, args: T::Array[AST::Node], params: T::Array[AST::Param], fn_nodes: FnNodes, facts_by_name: T::Hash[String, FunctionFacts], schema_lookup: T.nilable(Proc)).void } private_class_method def self.mark_receiver_for_owned_sink!(receiver, args, params, fn_nodes, facts_by_name, schema_lookup) diff --git a/tools/fuzz/mutants/patches/execution_boundary_parallel_accept.patch b/tools/fuzz/mutants/patches/execution_boundary_parallel_accept.patch index c194e1c3f..55b89a18c 100644 --- a/tools/fuzz/mutants/patches/execution_boundary_parallel_accept.patch +++ b/tools/fuzz/mutants/patches/execution_boundary_parallel_accept.patch @@ -1,6 +1,6 @@ -diff --git a/src/annotator/helpers/capabilities.rb b/src/annotator/helpers/capabilities.rb ---- a/src/annotator/helpers/capabilities.rb -+++ b/src/annotator/helpers/capabilities.rb +diff --git a/compiler/ruby/annotator/helpers/capabilities.rb b/compiler/ruby/annotator/helpers/capabilities.rb +--- a/compiler/ruby/annotator/helpers/capabilities.rb ++++ b/compiler/ruby/annotator/helpers/capabilities.rb @@ -1246,7 +1246,7 @@ module CapabilityHelper def validate_capture_analysis!(node, analysis, is_parallel, is_pinned) T.bind(self, SemanticAnnotator) rescue nil @@ -10,9 +10,9 @@ diff --git a/src/annotator/helpers/capabilities.rb b/src/annotator/helpers/capab if analysis.has_local error!(node, :LOCAL_VAR_NOT_IN_PARALLEL) end -diff --git a/src/annotator/domains/execution_boundaries.rb b/src/annotator/domains/execution_boundaries.rb ---- a/src/annotator/domains/execution_boundaries.rb -+++ b/src/annotator/domains/execution_boundaries.rb +diff --git a/compiler/ruby/annotator/domains/execution_boundaries.rb b/compiler/ruby/annotator/domains/execution_boundaries.rb +--- a/compiler/ruby/annotator/domains/execution_boundaries.rb ++++ b/compiler/ruby/annotator/domains/execution_boundaries.rb @@ -643,7 +643,7 @@ class SemanticAnnotator analysis_result = T.must(full_analysis) branch.capture_analysis = analysis_result @@ -31,9 +31,9 @@ diff --git a/src/annotator/domains/execution_boundaries.rb b/src/annotator/domai error!(node, :LOCAL_VAR_NOT_IN_PARALLEL) if analysis_result.has_local error!(node, :MULTIOWNED_NOT_IN_PARALLEL) if analysis_result.has_rc end -diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb ---- a/src/mir/mir_checker.rb -+++ b/src/mir/mir_checker.rb +diff --git a/compiler/ruby/mir/mir_checker.rb b/compiler/ruby/mir/mir_checker.rb +--- a/compiler/ruby/mir/mir_checker.rb ++++ b/compiler/ruby/mir/mir_checker.rb @@ -2520,6 +2520,7 @@ class MIRChecker next unless fact.dispatch == :parallel next if capture.parallel_safe diff --git a/tools/fuzz/mutants/patches/fn_type_reentrant_constraint_accept.patch b/tools/fuzz/mutants/patches/fn_type_reentrant_constraint_accept.patch index 159e88e46..0cbd056bd 100644 --- a/tools/fuzz/mutants/patches/fn_type_reentrant_constraint_accept.patch +++ b/tools/fuzz/mutants/patches/fn_type_reentrant_constraint_accept.patch @@ -1,6 +1,6 @@ -diff --git a/src/annotator/helpers/function_analysis.rb b/src/annotator/helpers/function_analysis.rb ---- a/src/annotator/helpers/function_analysis.rb -+++ b/src/annotator/helpers/function_analysis.rb +diff --git a/compiler/ruby/annotator/helpers/function_analysis.rb b/compiler/ruby/annotator/helpers/function_analysis.rb +--- a/compiler/ruby/annotator/helpers/function_analysis.rb ++++ b/compiler/ruby/annotator/helpers/function_analysis.rb @@ -698,6 +698,7 @@ module FunctionAnalysis actual_type = facts.arg_node.full_type!(context: "fn-typed argument") diff --git a/tools/fuzz/mutants/patches/fsm_suspend_returns_done.patch b/tools/fuzz/mutants/patches/fsm_suspend_returns_done.patch index 313cedde7..15616b69c 100644 --- a/tools/fuzz/mutants/patches/fsm_suspend_returns_done.patch +++ b/tools/fuzz/mutants/patches/fsm_suspend_returns_done.patch @@ -1,7 +1,7 @@ -diff --git a/src/backends/fsm_wrapper_emitter.rb b/src/backends/fsm_wrapper_emitter.rb +diff --git a/compiler/ruby/backends/fsm_wrapper_emitter.rb b/compiler/ruby/backends/fsm_wrapper_emitter.rb index 000000000..000000000 100644 ---- a/src/backends/fsm_wrapper_emitter.rb -+++ b/src/backends/fsm_wrapper_emitter.rb +--- a/compiler/ruby/backends/fsm_wrapper_emitter.rb ++++ b/compiler/ruby/backends/fsm_wrapper_emitter.rb @@ -414,13 +414,13 @@ module FsmWrapperEmitter when MIR::FsmTailYield [ diff --git a/tools/fuzz/mutants/patches/function_arg_pointer_noop.patch b/tools/fuzz/mutants/patches/function_arg_pointer_noop.patch index e89a83a23..a536c05a8 100644 --- a/tools/fuzz/mutants/patches/function_arg_pointer_noop.patch +++ b/tools/fuzz/mutants/patches/function_arg_pointer_noop.patch @@ -1,6 +1,6 @@ -diff --git a/src/mir/lowering/functions.rb b/src/mir/lowering/functions.rb ---- a/src/mir/lowering/functions.rb -+++ b/src/mir/lowering/functions.rb +diff --git a/compiler/ruby/mir/lowering/functions.rb b/compiler/ruby/mir/lowering/functions.rb +--- a/compiler/ruby/mir/lowering/functions.rb ++++ b/compiler/ruby/mir/lowering/functions.rb @@ -1018,7 +1018,7 @@ module MIRLoweringFunctions return MIR::ItemsAccess.new(arg, true) end diff --git a/tools/fuzz/mutants/patches/local_frame_decls_stdlib_provenance.patch b/tools/fuzz/mutants/patches/local_frame_decls_stdlib_provenance.patch index 9408a0c7b..a443c0609 100644 --- a/tools/fuzz/mutants/patches/local_frame_decls_stdlib_provenance.patch +++ b/tools/fuzz/mutants/patches/local_frame_decls_stdlib_provenance.patch @@ -1,6 +1,6 @@ -diff --git a/src/mir/lowering/control_flow.rb b/src/mir/lowering/control_flow.rb ---- a/src/mir/lowering/control_flow.rb -+++ b/src/mir/lowering/control_flow.rb +diff --git a/compiler/ruby/mir/lowering/control_flow.rb b/compiler/ruby/mir/lowering/control_flow.rb +--- a/compiler/ruby/mir/lowering/control_flow.rb ++++ b/compiler/ruby/mir/lowering/control_flow.rb @@ -246,5 +246,5 @@ module MIRLoweringControlFlow sig { params(stmts: T::Array[MIR::Node], mark_per_iter: T.nilable(T::Boolean)).void } diff --git a/tools/fuzz/mutants/patches/lower_if_cond_pending_leak.patch b/tools/fuzz/mutants/patches/lower_if_cond_pending_leak.patch index 84bd8d263..3e0834350 100644 --- a/tools/fuzz/mutants/patches/lower_if_cond_pending_leak.patch +++ b/tools/fuzz/mutants/patches/lower_if_cond_pending_leak.patch @@ -1,6 +1,6 @@ -diff --git a/src/mir/hoist.rb b/src/mir/hoist.rb ---- a/src/mir/hoist.rb -+++ b/src/mir/hoist.rb +diff --git a/compiler/ruby/mir/hoist.rb b/compiler/ruby/mir/hoist.rb +--- a/compiler/ruby/mir/hoist.rb ++++ b/compiler/ruby/mir/hoist.rb @@ -499,12 +499,8 @@ module Hoist sig { params(blk: T.proc.returns(T.untyped)).returns([T.untyped, T::Array[T.untyped]]) } def lower_head(&blk) diff --git a/tools/fuzz/mutants/patches/mir_checker_skip_aggregate_child_alloc_mismatch.patch b/tools/fuzz/mutants/patches/mir_checker_skip_aggregate_child_alloc_mismatch.patch index 12cd74740..f62af7515 100644 --- a/tools/fuzz/mutants/patches/mir_checker_skip_aggregate_child_alloc_mismatch.patch +++ b/tools/fuzz/mutants/patches/mir_checker_skip_aggregate_child_alloc_mismatch.patch @@ -1,7 +1,7 @@ -diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb +diff --git a/compiler/ruby/mir/mir_checker.rb b/compiler/ruby/mir/mir_checker.rb index 000000000..000000000 100644 ---- a/src/mir/mir_checker.rb -+++ b/src/mir/mir_checker.rb +--- a/compiler/ruby/mir/mir_checker.rb ++++ b/compiler/ruby/mir/mir_checker.rb @@ -1197,7 +1197,7 @@ class MIRChecker when MIR::Ident child_alloc = alloc_by_name[expr.name] diff --git a/tools/fuzz/mutants/patches/mir_checker_skip_call_contracts.patch b/tools/fuzz/mutants/patches/mir_checker_skip_call_contracts.patch index 1ebb7f3a2..3e8cad594 100644 --- a/tools/fuzz/mutants/patches/mir_checker_skip_call_contracts.patch +++ b/tools/fuzz/mutants/patches/mir_checker_skip_call_contracts.patch @@ -1,7 +1,7 @@ -diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb +diff --git a/compiler/ruby/mir/mir_checker.rb b/compiler/ruby/mir/mir_checker.rb index 000000000..000000000 100644 ---- a/src/mir/mir_checker.rb -+++ b/src/mir/mir_checker.rb +--- a/compiler/ruby/mir/mir_checker.rb ++++ b/compiler/ruby/mir/mir_checker.rb @@ -439,6 +439,5 @@ class MIRChecker verify_cleanup_required_finalizers!(allocs, cleanups, errdefer_destroy_names, transfers) verify_ownership_consumption_operands!(structural_ownership_nodes) diff --git a/tools/fuzz/mutants/patches/mir_checker_skip_cleanup_source_owns.patch b/tools/fuzz/mutants/patches/mir_checker_skip_cleanup_source_owns.patch index 019f76b46..df4f84168 100644 --- a/tools/fuzz/mutants/patches/mir_checker_skip_cleanup_source_owns.patch +++ b/tools/fuzz/mutants/patches/mir_checker_skip_cleanup_source_owns.patch @@ -1,7 +1,7 @@ -diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb +diff --git a/compiler/ruby/mir/mir_checker.rb b/compiler/ruby/mir/mir_checker.rb index 000000000..000000000 100644 ---- a/src/mir/mir_checker.rb -+++ b/src/mir/mir_checker.rb +--- a/compiler/ruby/mir/mir_checker.rb ++++ b/compiler/ruby/mir/mir_checker.rb @@ -1340,6 +1340,7 @@ class MIRChecker sig { params(node: MIR::Let, cleanup: T.any(MIR::Cleanup, MIR::ErrCleanup)).returns(T::Boolean) } diff --git a/tools/fuzz/mutants/patches/mir_checker_skip_inline_alloc_mismatch.patch b/tools/fuzz/mutants/patches/mir_checker_skip_inline_alloc_mismatch.patch index eb5c3b065..7990c9ae1 100644 --- a/tools/fuzz/mutants/patches/mir_checker_skip_inline_alloc_mismatch.patch +++ b/tools/fuzz/mutants/patches/mir_checker_skip_inline_alloc_mismatch.patch @@ -1,7 +1,7 @@ -diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb +diff --git a/compiler/ruby/mir/mir_checker.rb b/compiler/ruby/mir/mir_checker.rb index 000000000..000000000 100644 ---- a/src/mir/mir_checker.rb -+++ b/src/mir/mir_checker.rb +--- a/compiler/ruby/mir/mir_checker.rb ++++ b/compiler/ruby/mir/mir_checker.rb @@ -2091,7 +2091,7 @@ class MIRChecker # Check primary allocator. if alloc_metadata.primary diff --git a/tools/fuzz/mutants/patches/mir_checker_skip_linear_use_after_transfer.patch b/tools/fuzz/mutants/patches/mir_checker_skip_linear_use_after_transfer.patch index ba4092969..a9d86ddda 100644 --- a/tools/fuzz/mutants/patches/mir_checker_skip_linear_use_after_transfer.patch +++ b/tools/fuzz/mutants/patches/mir_checker_skip_linear_use_after_transfer.patch @@ -1,7 +1,7 @@ -diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb +diff --git a/compiler/ruby/mir/mir_checker.rb b/compiler/ruby/mir/mir_checker.rb index 000000000..000000000 100644 ---- a/src/mir/mir_checker.rb -+++ b/src/mir/mir_checker.rb +--- a/compiler/ruby/mir/mir_checker.rb ++++ b/compiler/ruby/mir/mir_checker.rb @@ -981,9 +981,7 @@ class MIRChecker end linear_expr_ident_names(expr).each do |name| diff --git a/tools/fuzz/mutants/patches/mir_checker_skip_ownership_surface_finalized.patch b/tools/fuzz/mutants/patches/mir_checker_skip_ownership_surface_finalized.patch index 95c765dd7..f0d0001fe 100644 --- a/tools/fuzz/mutants/patches/mir_checker_skip_ownership_surface_finalized.patch +++ b/tools/fuzz/mutants/patches/mir_checker_skip_ownership_surface_finalized.patch @@ -1,6 +1,6 @@ -diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb ---- a/src/mir/mir_checker.rb -+++ b/src/mir/mir_checker.rb +diff --git a/compiler/ruby/mir/mir_checker.rb b/compiler/ruby/mir/mir_checker.rb +--- a/compiler/ruby/mir/mir_checker.rb ++++ b/compiler/ruby/mir/mir_checker.rb @@ -2410,6 +2410,7 @@ # unrelated protocols and memory bugs can slip through opaque code. sig { params(nodes: T::Array[MIR::Node], facts: T::Array[MIR::Node]).void } diff --git a/tools/fuzz/mutants/patches/mir_emitter_cleanup_noop.patch b/tools/fuzz/mutants/patches/mir_emitter_cleanup_noop.patch index 5cc808ddf..8308d6f5e 100644 --- a/tools/fuzz/mutants/patches/mir_emitter_cleanup_noop.patch +++ b/tools/fuzz/mutants/patches/mir_emitter_cleanup_noop.patch @@ -1,6 +1,6 @@ -diff --git a/src/backends/mir_emitter.rb b/src/backends/mir_emitter.rb ---- a/src/backends/mir_emitter.rb -+++ b/src/backends/mir_emitter.rb +diff --git a/compiler/ruby/backends/mir_emitter.rb b/compiler/ruby/backends/mir_emitter.rb +--- a/compiler/ruby/backends/mir_emitter.rb ++++ b/compiler/ruby/backends/mir_emitter.rb @@ -2244,4 +2244,5 @@ class MIREmitter def emit_cleanup(node, errdefer: false) + return "" # MUTANT: cleanup-bearing MIR nodes no longer emit runtime finalizers. diff --git a/tools/fuzz/mutants/patches/mir_emitter_move_mark_noop.patch b/tools/fuzz/mutants/patches/mir_emitter_move_mark_noop.patch index 64a3a5081..6da6cb2b7 100644 --- a/tools/fuzz/mutants/patches/mir_emitter_move_mark_noop.patch +++ b/tools/fuzz/mutants/patches/mir_emitter_move_mark_noop.patch @@ -1,6 +1,6 @@ -diff --git a/src/backends/mir_emitter.rb b/src/backends/mir_emitter.rb ---- a/src/backends/mir_emitter.rb -+++ b/src/backends/mir_emitter.rb +diff --git a/compiler/ruby/backends/mir_emitter.rb b/compiler/ruby/backends/mir_emitter.rb +--- a/compiler/ruby/backends/mir_emitter.rb ++++ b/compiler/ruby/backends/mir_emitter.rb @@ -2289,7 +2289,7 @@ class MIREmitter sig { params(node: MIR::MoveMark).returns(String) } diff --git a/tools/fuzz/mutants/patches/or_rescue_skip_catch_placement.patch b/tools/fuzz/mutants/patches/or_rescue_skip_catch_placement.patch index 8abfeb5b1..453163a57 100644 --- a/tools/fuzz/mutants/patches/or_rescue_skip_catch_placement.patch +++ b/tools/fuzz/mutants/patches/or_rescue_skip_catch_placement.patch @@ -1,6 +1,6 @@ -diff --git a/src/mir/mir_lowering.rb b/src/mir/mir_lowering.rb ---- a/src/mir/mir_lowering.rb -+++ b/src/mir/mir_lowering.rb +diff --git a/compiler/ruby/mir/mir_lowering.rb b/compiler/ruby/mir/mir_lowering.rb +--- a/compiler/ruby/mir/mir_lowering.rb ++++ b/compiler/ruby/mir/mir_lowering.rb @@ -676,7 +676,7 @@ end diff --git a/tools/fuzz/mutants/patches/owned_branch_destination_noop.patch b/tools/fuzz/mutants/patches/owned_branch_destination_noop.patch index 6c5930caf..66c6f3a56 100644 --- a/tools/fuzz/mutants/patches/owned_branch_destination_noop.patch +++ b/tools/fuzz/mutants/patches/owned_branch_destination_noop.patch @@ -1,6 +1,6 @@ -diff --git a/src/mir/mir_lowering.rb b/src/mir/mir_lowering.rb ---- a/src/mir/mir_lowering.rb -+++ b/src/mir/mir_lowering.rb +diff --git a/compiler/ruby/mir/mir_lowering.rb b/compiler/ruby/mir/mir_lowering.rb +--- a/compiler/ruby/mir/mir_lowering.rb ++++ b/compiler/ruby/mir/mir_lowering.rb @@ -798,6 +798,7 @@ sig { params(mir: MIR::Node, dst_ti: Type, dest_alloc: Symbol).returns(MIR::Node) } diff --git a/tools/fuzz/mutants/patches/tight_loop_validation_skip.patch b/tools/fuzz/mutants/patches/tight_loop_validation_skip.patch index 4446a3644..57972debf 100644 --- a/tools/fuzz/mutants/patches/tight_loop_validation_skip.patch +++ b/tools/fuzz/mutants/patches/tight_loop_validation_skip.patch @@ -1,6 +1,6 @@ -diff --git a/src/annotator/helpers/effects.rb b/src/annotator/helpers/effects.rb ---- a/src/annotator/helpers/effects.rb -+++ b/src/annotator/helpers/effects.rb +diff --git a/compiler/ruby/annotator/helpers/effects.rb b/compiler/ruby/annotator/helpers/effects.rb +--- a/compiler/ruby/annotator/helpers/effects.rb ++++ b/compiler/ruby/annotator/helpers/effects.rb @@ -1156,6 +1156,7 @@ module EffectTracker sig { params(stmts: AstScanInput, loop_node: TightLoopNode).void } def validate_tight_body!(stmts, loop_node) diff --git a/tools/fuzz/mutants/patches/union_match_drops_payload_capture.patch b/tools/fuzz/mutants/patches/union_match_drops_payload_capture.patch index 510f1fe9e..7f9b861aa 100644 --- a/tools/fuzz/mutants/patches/union_match_drops_payload_capture.patch +++ b/tools/fuzz/mutants/patches/union_match_drops_payload_capture.patch @@ -1,7 +1,7 @@ -diff --git a/src/backends/mir_emitter.rb b/src/backends/mir_emitter.rb +diff --git a/compiler/ruby/backends/mir_emitter.rb b/compiler/ruby/backends/mir_emitter.rb index 000000000..000000000 100644 ---- a/src/backends/mir_emitter.rb -+++ b/src/backends/mir_emitter.rb +--- a/compiler/ruby/backends/mir_emitter.rb ++++ b/compiler/ruby/backends/mir_emitter.rb @@ -1876,6 +1876,6 @@ class MIREmitter body = emit_body(arm.body) payload = arm.payload diff --git a/tools/generate_generalized_gem_sarif.rb b/tools/generate_generalized_gem_sarif.rb index 2de9c0c4e..8555bb285 100644 --- a/tools/generate_generalized_gem_sarif.rb +++ b/tools/generate_generalized_gem_sarif.rb @@ -5,6 +5,7 @@ require "json" require "open3" require "optparse" +require_relative "sarif_result_cap" ROOT = File.expand_path("..", __dir__) DECOMPLEX_SARIF_MAX_RESULTS = Integer(ENV.fetch("DECOMPLEX_CI_SARIF_MAX_RESULTS", "1000")) @@ -134,11 +135,15 @@ def cap_sarif_results(path, max_results) return unless results.is_a?(Array) && results.size > max_results original_count = results.size - run["results"] = results.take(max_results) + retained, selection = SarifResultCap.select(results, max_results) + run["results"] = retained properties = run["properties"] ||= {} properties["decomplex.sarif_results_original_count"] = original_count properties["decomplex.sarif_results_limit"] = max_results properties["decomplex.sarif_results_truncated_count"] = original_count - max_results + properties["decomplex.sarif_results_original_by_rule"] = selection.fetch("original_by_rule") + properties["decomplex.sarif_results_retained_by_rule"] = selection.fetch("retained_by_rule") + properties["decomplex.sarif_results_truncated_by_rule"] = selection.fetch("truncated_by_rule") File.write(path, JSON.pretty_generate(sarif)) warn "truncated #{path} results from #{original_count} to #{max_results}" end @@ -177,10 +182,23 @@ def run_decomplex_rust(binary, files, out_dir, repo) warn "wrote #{md_out}" end -def build_espalier_manifest(files, repo) +def build_espalier_manifest(files, repo, nil_kill_evidence = nil) evidence = Espalier::StaticEvidence.build(files, root: repo) modules = Espalier::StaticEvidence.project_modules(evidence) - Espalier::Aggregator.new.aggregate(modules) + if nil_kill_evidence + static_data = nil_kill_evidence["static"] || {} + wrapped_data = FactMine::Syntax::TypeExpr.wrap_types!(static_data) + espalier_nk = Espalier::NilKillEvidence.new(wrapped_data) + espalier_nk.apply!(modules) + aggregator = Espalier::Aggregator.new( + nil_kill_data: espalier_nk.method_signatures, + nil_kill_loops: espalier_nk.loop_counts, + nil_kill_evidence: espalier_nk + ) + aggregator.aggregate(modules) + else + Espalier::Aggregator.new.aggregate(modules) + end end @@ -295,14 +313,14 @@ def build_nil_kill_evidence(files, repo) write(File.join(out_dir, "slopcop.md"), slopcop.to_markdown) Dir.chdir(repo) do - manifest = build_espalier_manifest(rel_files, repo) + nil_kill_evidence = build_nil_kill_evidence(rel_files, repo) + manifest = build_espalier_manifest(rel_files, repo, nil_kill_evidence) write(File.join(out_dir, "espalier.sarif"), Espalier::Formatter.to_sarif(manifest)) write( File.join(out_dir, "espalier.md"), Espalier::Reporter.new(manifest, root: repo, link_base: out_dir).to_markdown ) - nil_kill_evidence = build_nil_kill_evidence(rel_files, repo) nil_kill_report = NilKill::Report.new(["--format", "sarif"], evidence: nil_kill_evidence) write(File.join(out_dir, "nil-kill.sarif"), nil_kill_report.to_sarif(nil_kill_evidence)) write( diff --git a/tools/generate_sql_cov_sarif.rb b/tools/generate_sql_cov_sarif.rb new file mode 100755 index 000000000..dab4287c4 --- /dev/null +++ b/tools/generate_sql_cov_sarif.rb @@ -0,0 +1,141 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" +require "json" +require "open3" +require "optparse" + +ROOT = File.expand_path("..", __dir__) + +options = { + repo: ".", + out_dir: "tmp/generalized-gems-sarif", + setup: "gems/lineage/sql/storage/init_schema.sql", + sql_cov_bin: "gems/sql-cov/target/release/sql-cov" +} + +OptionParser.new do |parser| + parser.banner = "Usage: generate_sql_cov_sarif.rb [options]" + parser.on("--repo=PATH") { |value| options[:repo] = value } + parser.on("--out-dir=PATH") { |value| options[:out_dir] = value } + parser.on("--setup=PATH") { |value| options[:setup] = value } + parser.on("--sql-cov-bin=PATH") { |value| options[:sql_cov_bin] = value } +end.parse! + +repo = File.realpath(options[:repo]) +out_dir = File.expand_path(options[:out_dir]) +setup_file = File.expand_path(options[:setup], repo) +sql_cov_bin = File.expand_path(options[:sql_cov_bin], ROOT) + +FileUtils.mkdir_p(out_dir) + +# Find all SQL files in gems/lineage/sql +sql_files = Dir.glob(File.join(repo, "gems/lineage/sql/**/*.sql")) + .reject { |path| path == setup_file || File.basename(path) == "ensure_natural_key_indexes.sql" } + .sort + +warn "Found #{sql_files.size} SQL files to scan" + +rules = {} +results = [] +all_unresolved = [] + +sql_files.each do |file| + rel_path = file.sub("#{repo}/", "") + cmd = [ + sql_cov_bin, + "hazards", + "--input", file, + "--setup", setup_file, + "--dialect", "sqlite", + "--format", "sarif", + "--sqlfluff" + ] + + stdout, stderr, status = Open3.capture3(*cmd) + unless status.success? + warn "Failed to scan #{rel_path}: #{stderr}" + next + end + + begin + sarif_doc = JSON.parse(stdout) + run = sarif_doc["runs"]&.first + if run + # Extract rules + run["tool"]&.[]("driver")&.[]("rules")&.each do |rule| + rules[rule["id"]] = rule + end + + # Extract results + run["results"]&.each do |result| + # Fix file URI paths to be relative for GitHub actions + if result["locations"] + result["locations"].each do |loc| + if loc["physicalLocation"] && loc["physicalLocation"]["artifactLocation"] + loc["physicalLocation"]["artifactLocation"]["uri"] = rel_path + end + end + end + results << result + end + + # Extract unresolved schema facts + run["properties"]&.[]("unresolvedSchemaFacts")&.each do |fact| + all_unresolved << "#{rel_path}: #{fact}" + end + end + rescue => e + warn "Failed to parse sql-cov output for #{rel_path}: #{e.message}" + end +end + +# Build master SARIF document +sarif_doc = { + "$schema" => "https://json.schemastore.org/sarif-2.1.0.json", + "version" => "2.1.0", + "runs" => [{ + "tool" => { + "driver" => { + "name" => "sql-cov-hazards", + "informationUri" => "https://cuzzo.github.io/clear/blog/an-ode-to-sql/", + "semanticVersion" => "0.1.0", + "rules" => rules.values + } + }, + "results" => results, + "properties" => { + "format" => "sql-cov/hazard/sarif", + "dialect" => "sqlite", + "unresolvedSchemaFacts" => all_unresolved + } + }] +} + +# Write SARIF +sarif_path = File.join(out_dir, "sql-cov.sarif") +File.write(sarif_path, JSON.pretty_generate(sarif_doc)) +warn "Wrote #{sarif_path} with #{results.size} findings" + +# Write Markdown +md_path = File.join(out_dir, "sql-cov.md") +md_content = String.new("# SQL-cov Hazards Report\n\n") +if results.empty? + md_content << "No SQL logic hazards or three-valued logic bugs detected in database queries.\n" +else + md_content << "### Findings Summary\n\n" + md_content << "| File | Line | Expression | Hazard | Message | Tier |\n" + md_content << "| --- | --- | --- | --- | --- | --- |\n" + results.each do |res| + file = res["locations"]&.first&.[]("physicalLocation")&.[]("artifactLocation")&.[]("uri") || "unknown" + line = res["locations"]&.first&.[]("physicalLocation")&.[]("region")&.[]("startLine") || 0 + expr = res["locations"]&.first&.[]("physicalLocation")&.[]("region")&.[]("snippet")&.[]("text") || "" + hazard = res["properties"]&.[]("kind") || res["ruleId"] + msg = res["message"]&.[]("text") || "" + tier = res["properties"]&.[]("tier") || "T1" + md_content << "| `#{file}` | #{line} | `#{expr}` | **#{hazard}** | #{msg} | #{tier} |\n" + end +end +File.write(md_path, md_content) +warn "Wrote #{md_path}" diff --git a/tools/lineage_build_db.sh b/tools/lineage_build_db.sh new file mode 100755 index 000000000..6f9e25bd2 --- /dev/null +++ b/tools/lineage_build_db.sh @@ -0,0 +1,133 @@ +#!/bin/bash +set -e + +# tools/lineage_build_db.sh +# Rebuilds the Lineage database from scratch by running tests, generating coverage/mutants, and ingesting the results. + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT_DIR" + +DB_PATH=${1:-/tmp/lineage.db} +COMMIT_HASH=$(git rev-parse HEAD) + +echo "=== [1/5] Running Tests & Generating Coverage ===" +# Clean old coverage results +rm -rf coverage/.resultset.json coverage/coverage.xml + +# Ruby spec coverage (unit + integration) +echo "Running Ruby specs under coverage..." +COVERAGE=1 bundle exec prspec compiler/spec/ || true +COVERAGE=1 bundle exec prspec compiler/spec/ --tag integration || true + +# Transpile, corpus, and bc-lower coverages +echo "Running transpile-tests generation under coverage..." +COVERAGE=1 bundle exec ruby transpile-tests/gen.rb || true + +echo "Running corpus transpile coverage..." +COVERAGE=1 bundle exec ruby tools/corpus_transpile_coverage.rb || true + +echo "Running corpus runtime coverage..." +COVERAGE=1 bundle exec ruby tools/corpus_runtime_coverage.rb || true + +echo "Running bytecode lowering coverage..." +COVERAGE=1 bundle exec ruby tools/bc_lower_coverage.rb --jobs "$(nproc)" || true + +# Collate all Ruby coverage resultsets +echo "Collating Ruby coverage resultsets..." +bundle exec ruby compiler/spec/collate_coverage.rb + +# Zig unit/Loom/VOPR test coverage +echo "Running Zig unit tests under coverage (kcov)..." +cd zig +zig build test -Dcoverage || true +cd .. + +echo "=== [2/5] Running Mutant & SARIF Generators ===" +# Go mutants +echo "Running Go mutants..." +ruby tools/mutants/go_mutants.rb + +# Fuzz mutants +echo "Running fuzz mutants..." +bundle exec ruby tools/fuzz/mutants/run.rb --all --allow-dirty --exposure /tmp/clear-fuzz-mutants-new.json --out /tmp/clear-fuzz-mutants-out-new + +# Transpile mutants +echo "Running transpile mutants..." +bundle exec ruby tools/mutants/transpile_tests.rb --all --allow-dirty --exposure /tmp/clear-transpile-mutants-new.json --out /tmp/clear-transpile-mutants-out-new + +# SARIF findings for SlopCop, Espalier, NilKill, Decomplex +echo "Building decomplex-rust release binary..." +cargo build --release --manifest-path gems/decomplex/Cargo.toml +ruby tools/generate_generalized_gem_sarif.rb \ + --repo . \ + --out-dir tmp/generalized-gems-sarif \ + --decomplex-binary gems/decomplex/target/release/decomplex-rust \ + --coverage "$ROOT_DIR/coverage/.resultset.json" \ + --coverage "$ROOT_DIR/coverage/coverage.xml" \ + --coverage "$ROOT_DIR/zig/zig-out/coverage/merged/kcov-merged/cobertura.xml" \ + --coverage /tmp/cov-artifacts/ruby-gems/.resultset.json \ + --coverage /tmp/cov-artifacts/ruby-gems/coverage.xml \ + --coverage /tmp/cov-artifacts/fact/cobertura.xml \ + --coverage /tmp/cov-artifacts/decomplex/cobertura.xml \ + --coverage /tmp/cov-artifacts/lineage/cobertura.xml + +echo "=== [3/5] Importing Codebase & Coverage into Lineage ===" +gems/lineage/bin/lineage-import \ + --repo . \ + --db "$DB_PATH" \ + --out-dir tmp/lineage-import \ + --no-build-tools \ + --max-commits 100 \ + --coverage "$ROOT_DIR/coverage/coverage.xml" \ + --coverage "$ROOT_DIR/zig/zig-out/coverage/merged/kcov-merged/cobertura.xml" \ + --coverage /tmp/cov-artifacts/ruby-gems/.resultset.json \ + --coverage /tmp/cov-artifacts/ruby-gems/coverage.xml \ + --coverage /tmp/cov-artifacts/fact/cobertura.xml \ + --coverage /tmp/cov-artifacts/decomplex/cobertura.xml \ + --coverage /tmp/cov-artifacts/lineage/cobertura.xml \ + --sarif-input tmp/generalized-gems-sarif + +echo "=== [4/5] Ingesting Mutant & Test Exposure Facts ===" +cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-test-exposure --db "$DB_PATH" --repo . --commit "$COMMIT_HASH" --input /tmp/clear-fuzz-mutants-new.json +cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-test-exposure --db "$DB_PATH" --repo . --commit "$COMMIT_HASH" --input /tmp/clear-transpile-mutants-new.json +if [ -f /tmp/zig-system-exposure.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-test-exposure --db "$DB_PATH" --repo . --commit "$COMMIT_HASH" --input /tmp/zig-system-exposure.json +fi + +# Stochastic/Unit mutant facts +cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/boobytrap-go-mutants.json --commit "$COMMIT_HASH" --test-type unit +if [ -f /tmp/rust-mutants-lineage-100/mutant-facts.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/rust-mutants-lineage-100/mutant-facts.json --commit "$COMMIT_HASH" --test-type unit +fi +if [ -f /tmp/litedb-mutant-facts-normalized.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/litedb-mutant-facts-normalized.json --commit "$COMMIT_HASH" --test-type unit +fi + +# PR 141 shards (if present) +for file in /tmp/pr141-*/mutant-facts.json; do + if [ -f "$file" ]; then + echo "Ingesting shard: $file" + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input "$file" --commit "$COMMIT_HASH" + fi +done + +# ClearParser mutants (if present) +if [ -f /tmp/ruby-mutants-parser-new/mutant-facts.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/ruby-mutants-parser-new/mutant-facts.json --commit "$COMMIT_HASH" +fi + +# Zig mutants shards +for i in {0..7}; do + if [ -f "/tmp/zig-mutants-runtime-final-normalized/shard-$i.json" ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input "/tmp/zig-mutants-runtime-final-normalized/shard-$i.json" --commit "$COMMIT_HASH" --test-type unit + fi +done + +if [ -f /tmp/scheduler-mutants.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/scheduler-mutants.json --commit "$COMMIT_HASH" --test-type unit +fi + +echo "=== [5/5] Refreshing UI Summaries ===" +cargo run --release --manifest-path gems/lineage/Cargo.toml -- refresh-ui --db "$DB_PATH" + +echo "=== Lineage Database Rebuild Complete! ===" diff --git a/tools/lineage_update_commit.sh b/tools/lineage_update_commit.sh new file mode 100755 index 000000000..b47759c68 --- /dev/null +++ b/tools/lineage_update_commit.sh @@ -0,0 +1,127 @@ +#!/bin/bash +set -e + +# tools/lineage_update_commit.sh +# Updates the Lineage database for a new commit by running tests, generating coverage/SARIF, +# and ingesting the results, reusing pre-existing mutant facts. + +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "$ROOT_DIR" + +DB_PATH=${1:-/tmp/lineage.db} +COMMIT_HASH=$(git rev-parse HEAD) + +# Clean old coverage results +rm -rf coverage/.resultset.json coverage/coverage.xml + +# Ruby spec coverage (unit + integration) +echo "Running Ruby specs under coverage..." +COVERAGE=1 bundle exec prspec compiler/spec/ || true +COVERAGE=1 bundle exec prspec compiler/spec/ --tag integration || true + +# Transpile, corpus, and bc-lower coverages +echo "Running transpile-tests generation under coverage..." +COVERAGE=1 bundle exec ruby transpile-tests/gen.rb || true + +echo "Running corpus transpile coverage..." +COVERAGE=1 bundle exec ruby tools/corpus_transpile_coverage.rb || true + +echo "Running corpus runtime coverage..." +COVERAGE=1 bundle exec ruby tools/corpus_runtime_coverage.rb || true + +echo "Running bytecode lowering coverage..." +COVERAGE=1 bundle exec ruby tools/bc_lower_coverage.rb --jobs "$(nproc)" || true + +# Collate all Ruby coverage resultsets +echo "Collating Ruby coverage resultsets..." +bundle exec ruby compiler/spec/collate_coverage.rb + +# Zig unit/Loom/VOPR test coverage +echo "Running Zig unit tests under coverage (kcov)..." +cd zig +zig build test -Dcoverage || true +cd .. + +echo "=== [2/4] Generating SARIF Findings ===" +# SARIF findings for SlopCop, Espalier, NilKill, Decomplex +echo "Building decomplex-rust release binary..." +cargo build --release --manifest-path gems/decomplex/Cargo.toml +ruby tools/generate_generalized_gem_sarif.rb \ + --repo . \ + --out-dir tmp/generalized-gems-sarif \ + --decomplex-binary gems/decomplex/target/release/decomplex-rust \ + --coverage "$ROOT_DIR/coverage/.resultset.json" \ + --coverage "$ROOT_DIR/coverage/coverage.xml" \ + --coverage "$ROOT_DIR/zig/zig-out/coverage/merged/kcov-merged/cobertura.xml" \ + --coverage /tmp/cov-artifacts/ruby-gems/.resultset.json \ + --coverage /tmp/cov-artifacts/ruby-gems/coverage.xml \ + --coverage /tmp/cov-artifacts/fact/cobertura.xml \ + --coverage /tmp/cov-artifacts/decomplex/cobertura.xml \ + --coverage /tmp/cov-artifacts/lineage/cobertura.xml + +echo "=== [3/4] Importing Codebase & Coverage into Lineage ===" +gems/lineage/bin/lineage-import \ + --repo . \ + --db "$DB_PATH" \ + --out-dir tmp/lineage-import \ + --no-build-tools \ + --max-commits 100 \ + --coverage "$ROOT_DIR/coverage/coverage.xml" \ + --coverage "$ROOT_DIR/zig/zig-out/coverage/merged/kcov-merged/cobertura.xml" \ + --coverage /tmp/cov-artifacts/ruby-gems/.resultset.json \ + --coverage /tmp/cov-artifacts/ruby-gems/coverage.xml \ + --coverage /tmp/cov-artifacts/fact/cobertura.xml \ + --coverage /tmp/cov-artifacts/decomplex/cobertura.xml \ + --coverage /tmp/cov-artifacts/lineage/cobertura.xml \ + --sarif-input tmp/generalized-gems-sarif + +echo "=== [4/4] Ingesting Mutant & Test Exposure Facts ===" +if [ -f /tmp/clear-fuzz-mutants-new.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-test-exposure --db "$DB_PATH" --repo . --commit "$COMMIT_HASH" --input /tmp/clear-fuzz-mutants-new.json +fi +if [ -f /tmp/clear-transpile-mutants-new.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-test-exposure --db "$DB_PATH" --repo . --commit "$COMMIT_HASH" --input /tmp/clear-transpile-mutants-new.json +fi +if [ -f /tmp/zig-system-exposure.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-test-exposure --db "$DB_PATH" --repo . --commit "$COMMIT_HASH" --input /tmp/zig-system-exposure.json +fi + +# Stochastic/Unit mutant facts +if [ -f /tmp/rust-mutants-lineage-100/mutant-facts.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/rust-mutants-lineage-100/mutant-facts.json --commit "$COMMIT_HASH" --test-type unit +fi +if [ -f /tmp/boobytrap-go-mutants.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/boobytrap-go-mutants.json --commit "$COMMIT_HASH" --test-type unit +fi +if [ -f /tmp/litedb-mutant-facts-normalized.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/litedb-mutant-facts-normalized.json --commit "$COMMIT_HASH" --test-type unit +fi + +# PR 141 shards (if present) +for file in /tmp/pr141-*/mutant-facts.json; do + if [ -f "$file" ]; then + echo "Ingesting shard: $file" + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input "$file" --commit "$COMMIT_HASH" + fi +done + +# ClearParser mutants (if present) +if [ -f /tmp/ruby-mutants-parser-new/mutant-facts.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/ruby-mutants-parser-new/mutant-facts.json --commit "$COMMIT_HASH" +fi + +# Zig mutants shards +for i in {0..7}; do + if [ -f "/tmp/zig-mutants-runtime-final-normalized/shard-$i.json" ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input "/tmp/zig-mutants-runtime-final-normalized/shard-$i.json" --commit "$COMMIT_HASH" --test-type unit + fi +done + +if [ -f /tmp/scheduler-mutants.json ]; then + cargo run --release --manifest-path gems/lineage/Cargo.toml -- ingest-mutants --db "$DB_PATH" --repo . --input /tmp/scheduler-mutants.json --commit "$COMMIT_HASH" --test-type unit +fi + +echo "=== Refreshing UI Summaries ===" +cargo run --release --manifest-path gems/lineage/Cargo.toml -- refresh-ui --db "$DB_PATH" + +echo "=== Lineage Database Update Complete! ===" diff --git a/tools/mutants/go_mutants.rb b/tools/mutants/go_mutants.rb new file mode 100755 index 000000000..1d2836673 --- /dev/null +++ b/tools/mutants/go_mutants.rb @@ -0,0 +1,254 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'fileutils' +require 'json' +require 'open3' + +ROOT = File.expand_path('../..', __dir__) +BOOBYTRAP_SRC = File.join(ROOT, 'gems/boobytrap/src') + +class GoMutantsRunner + def initialize + @mutants = [] + @subjects = {} + end + + def scan_files + Dir.glob(File.join(BOOBYTRAP_SRC, '*.go')).each do |file_path| + next if file_path.end_with?('_test.go') + + rel_path = file_path.sub("#{ROOT}/", '') + lines = File.readlines(file_path) + current_func = '*' + lexer_state = :code + + lines.each_with_index do |line, line_idx| + clean_line, lexer_state = code_only_line(line, lexer_state) + + # Match function declarations + if clean_line =~ /^\s*func\s+(?:\([^)]+\)\s+)?([A-Za-z0-9_]+)/ + current_func = $1 + end + + # Scan for mutatable tokens + # Comparison operators + col_offset = 0 + loop do + match = clean_line.match(/(==|!=|<=|>=|<|>|\btrue\b|\bfalse\b)/, col_offset) + break unless match + + tok = match[1] + start_col = match.begin(0) + 1 + col_offset = match.end(0) + if %w[< >].include?(tok) + before = match.begin(0).positive? ? clean_line[match.begin(0) - 1] : nil + adjacent = [before, clean_line[match.end(0)]] + next if adjacent.include?(tok) || (tok == '<' && clean_line[match.end(0)] == '-') + end + + kind = nil + repl = nil + case tok + when '==' then kind = 'comparison_flip'; repl = '!=' + when '!=' then kind = 'comparison_flip'; repl = '==' + when '<=' then kind = 'comparison_flip'; repl = '>' + when '>=' then kind = 'comparison_flip'; repl = '<' + when '<' then kind = 'comparison_flip'; repl = '>=' + when '>' then kind = 'comparison_flip'; repl = '<=' + when 'true' then kind = 'bool_literal_flip'; repl = 'false' + when 'false' then kind = 'bool_literal_flip'; repl = 'true' + end + + next unless kind + + @mutants << { + file_path: file_path, + rel_path: rel_path, + line: line_idx + 1, + column: start_col, + original: tok, + replacement: repl, + kind: kind, + method: current_func + } + end + end + end + end + + def code_only_line(line, state) + output = line.dup + index = 0 + escaped = false + while index < line.length + char = line[index] + following = line[index + 1] + case state + when :block_comment + output[index] = ' ' unless char == "\n" + if char == '*' && following == '/' + output[index + 1] = ' ' + state = :code + index += 2 + else + index += 1 + end + when :double_quote, :rune + output[index] = ' ' unless char == "\n" + delimiter = state == :double_quote ? '"' : "'" + if escaped + escaped = false + elsif char == '\\' + escaped = true + elsif char == delimiter + state = :code + end + index += 1 + when :raw_string + output[index] = ' ' unless char == "\n" + state = :code if char == '`' + index += 1 + else + if char == '/' && following == '/' + output[index...line.length] = ' ' * (line.length - index) + break + elsif char == '/' && following == '*' + output[index, 2] = ' ' + state = :block_comment + index += 2 + elsif char == '"' + output[index] = ' ' + state = :double_quote + index += 1 + elsif char == "'" + output[index] = ' ' + state = :rune + index += 1 + elsif char == '`' + output[index] = ' ' + state = :raw_string + index += 1 + else + index += 1 + end + end + end + [output, state] + end + + def run(facts_out) + puts "Found #{@mutants.length} potential Go mutants." + + # Let's shard or cap to make sure it doesn't take too long + # We can skip equivalent ones or just run them in a fast way + # Cap the deterministic sample so routine runs remain bounded. + selected_mutants = @mutants.sample(100, random: Random.new(42)) + + results = [] + selected_mutants.each_with_index do |m, idx| + print "[#{idx + 1}/#{selected_mutants.length}] Mutating #{m[:rel_path]}:#{m[:line]}:#{m[:column]} in #{m[:method]} (#{m[:original]} -> #{m[:replacement]})... " + + # Apply mutation + orig_content = File.read(m[:file_path]) + lines = orig_content.lines + target_line = lines[m[:line] - 1] + + # Replace only the specific instance on that line + col_idx = m[:column] - 1 + mutated_line = target_line[0...col_idx] + m[:replacement] + target_line[(col_idx + m[:original].length)..-1] + lines[m[:line] - 1] = mutated_line + + stdout = '' + stderr = '' + status = nil + begin + File.write(m[:file_path], lines.join) + stdout, stderr, status = Open3.capture3( + 'timeout', '120', 'bundle', 'exec', 'go', 'test', './...', + chdir: BOOBYTRAP_SRC + ) + ensure + File.write(m[:file_path], orig_content) + end + + combined_output = "#{stdout}\n#{stderr}" + outcome = if status.exitstatus == 124 + 'timeout' + elsif status.success? + 'survived' + elsif combined_output.match?(/\[build failed\]|syntax error|undefined:|cannot use .* as |mismatched types?/i) + 'unviable' + else + 'killed' + end + + puts outcome + + results << { + id: "go:#{m[:rel_path]}:#{m[:line]}:#{m[:column]}:#{m[:kind]}", + file: m[:rel_path], + method: m[:method], + kind: m[:kind], + outcome: outcome, + line: m[:line], + column: m[:column], + exit_code: status.exitstatus + } + + # Update subjects + subj_key = "#{m[:rel_path]}:#{m[:method]}" + @subjects[subj_key] ||= { + file: m[:rel_path], + method: m[:method], + mutations: 0, + killed: 0, + alive: 0, + timeouts: 0, + unviable: 0, + skipped: 0 + } + + stats = @subjects[subj_key] + stats[:mutations] += 1 + if outcome == 'killed' + stats[:killed] += 1 + elsif outcome == 'survived' + stats[:alive] += 1 + elsif outcome == 'timeout' + stats[:timeouts] += 1 + else + stats[:unviable] += 1 + end + end + + # Format into mutant-facts/v1 + subjects_list = @subjects.values.map do |s| + kill_rate = s[:mutations] > 0 ? (s[:killed].to_f / s[:mutations] * 100.0).round(2) : 0.0 + s.merge( + kill_rate: kill_rate, + gate_status: 'advisory', + mutation_kind: 'stochastic' + ) + end + + payload = { + schema: 'mutant-facts/v1', + source: 'tools/go-mutants', + language: 'go', + mutation_kind: 'stochastic', + subjects: subjects_list, + mutants: results + } + + FileUtils.mkdir_p(File.dirname(facts_out)) + File.write(facts_out, JSON.pretty_generate(payload) + "\n") + puts "Wrote facts to #{facts_out}" + end +end + +if __FILE__ == $PROGRAM_NAME + runner = GoMutantsRunner.new + runner.scan_files + runner.run('/tmp/boobytrap-go-mutants.json') +end diff --git a/tools/sarif_result_cap.rb b/tools/sarif_result_cap.rb new file mode 100644 index 000000000..765eb0c98 --- /dev/null +++ b/tools/sarif_result_cap.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module SarifResultCap + module_function + + def select(results, limit) + results = Array(results) + return [results, counts(results, results)] unless limit&.positive? && results.length > limit + + indexed = results.each_with_index.group_by { |(result, _index)| rule_id(result) } + buckets = indexed.transform_values { |rows| rows.sort_by { |result, index| result_key(result, index) } } + ordered_rules = buckets.keys.sort_by { |rule| [bucket_tier(buckets.fetch(rule)), rule] } + selected = [] + + # Preserve detector/rule diversity before allowing prolific rules to use + # the remaining budget. + ordered_rules.first(limit).each do |rule| + selected << buckets.fetch(rule).shift + end + + weighted_schedule = ordered_rules.flat_map do |rule| + [rule] * tier_weight(bucket_tier(buckets.fetch(rule), selected, rule)) + end + while selected.length < limit && weighted_schedule.any? { |rule| !buckets.fetch(rule).empty? } + weighted_schedule.each do |rule| + row = buckets.fetch(rule).shift + next unless row + + selected << row + break if selected.length >= limit + end + end + + retained = selected.sort_by { |result, index| result_key(result, index) }.map(&:first) + [retained, counts(results, retained)] + end + + def counts(original, retained) + original_counts = rule_counts(original) + retained_counts = rule_counts(retained) + { + "original_by_rule" => original_counts, + "retained_by_rule" => retained_counts, + "truncated_by_rule" => original_counts.to_h do |rule, count| + [rule, count - retained_counts.fetch(rule, 0)] + end, + } + end + + def rule_counts(results) + Array(results).each_with_object(Hash.new(0)) do |result, out| + out[rule_id(result)] += 1 + end.sort.to_h + end + + def rule_id(result) + result["ruleId"].to_s.empty? ? "(unknown)" : result["ruleId"].to_s + end + + def tier(result) + value = result.dig("properties", "tier") || result.dig("properties", "decomplex_finding", "tier") + parsed = Integer(value, exception: false) + parsed&.positive? ? parsed : 3 + end + + def bucket_tier(rows, selected = [], rule = nil) + candidates = rows.map(&:first) + candidates.concat(selected.filter_map { |result, _index| result if rule_id(result) == rule }) if rule + candidates.map { |result| tier(result) }.min || 3 + end + + def tier_weight(value) + case value + when 1 then 3 + when 2 then 2 + else 1 + end + end + + def result_key(result, index) + level_rank = result["level"] == "error" ? 0 : result["level"] == "warning" ? 1 : 2 + [tier(result), level_rank, rule_id(result), index] + end +end diff --git a/tools/test/sarif_result_cap_test.rb b/tools/test/sarif_result_cap_test.rb new file mode 100644 index 000000000..a344d2cc4 --- /dev/null +++ b/tools/test/sarif_result_cap_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "../sarif_result_cap" + +class SarifResultCapTest < Minitest::Test + def result(rule, tier, index, level: "note") + { + "ruleId" => rule, + "level" => level, + "message" => { "text" => "#{rule}-#{index}" }, + "properties" => { "tier" => tier }, + } + end + + def test_prolific_rule_cannot_crowd_out_other_detectors + results = 100.times.map { |index| result("decomplex.noisy", 1, index, level: "warning") } + results.concat(10.times.map { |index| result("decomplex.medium", 2, index) }) + results.concat(5.times.map { |index| result("decomplex.small", 3, index) }) + + selected, stats = SarifResultCap.select(results, 12) + counts = SarifResultCap.rule_counts(selected) + + assert_equal 12, selected.length + assert_equal %w[decomplex.medium decomplex.noisy decomplex.small], counts.keys.sort + assert_operator counts.fetch("decomplex.noisy"), :>, counts.fetch("decomplex.small") + assert_equal 115, stats.fetch("original_by_rule").values.sum + assert_equal 103, stats.fetch("truncated_by_rule").values.sum + end + + def test_warning_precedes_note_within_the_same_tier_and_rule + results = [ + result("decomplex.one", 1, 0), + result("decomplex.one", 1, 1, level: "warning"), + result("decomplex.two", 1, 2), + ] + + selected, = SarifResultCap.select(results, 2) + one = selected.find { |row| row["ruleId"] == "decomplex.one" } + assert_equal "warning", one.fetch("level") + end + + def test_does_not_reorder_or_copy_when_under_limit + results = [result("decomplex.one", 1, 0), result("decomplex.two", 2, 1)] + selected, stats = SarifResultCap.select(results, 10) + assert_same results, selected + assert_equal({ "decomplex.one" => 1, "decomplex.two" => 1 }, stats.fetch("retained_by_rule")) + end +end