bulldogger 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +262 -0
- data/docs/design-decisions.md +142 -0
- data/docs/evidence-schema.md +388 -0
- data/docs/maintenance.md +92 -0
- data/docs/trace-schema.md +118 -0
- data/lib/bulldogger/capture.rb +98 -0
- data/lib/bulldogger/config.rb +57 -0
- data/lib/bulldogger/evidence.rb +106 -0
- data/lib/bulldogger/formatter.rb +103 -0
- data/lib/bulldogger/frame_source.rb +147 -0
- data/lib/bulldogger/integrations/minitest.rb +87 -0
- data/lib/bulldogger/integrations/rspec.rb +56 -0
- data/lib/bulldogger/minitest.rb +7 -0
- data/lib/bulldogger/pending.rb +58 -0
- data/lib/bulldogger/probe/bucket.rb +90 -0
- data/lib/bulldogger/probe/comparator.rb +95 -0
- data/lib/bulldogger/probe/method_stats.rb +215 -0
- data/lib/bulldogger/probe/raise_tracker.rb +141 -0
- data/lib/bulldogger/probe/registry.rb +32 -0
- data/lib/bulldogger/probe/session.rb +159 -0
- data/lib/bulldogger/probe/target.rb +13 -0
- data/lib/bulldogger/probe/target_resolver.rb +86 -0
- data/lib/bulldogger/probe/writer.rb +61 -0
- data/lib/bulldogger/probe.rb +36 -0
- data/lib/bulldogger/record/session.rb +334 -0
- data/lib/bulldogger/record/sqlite_converter.rb +86 -0
- data/lib/bulldogger/record/writer.rb +67 -0
- data/lib/bulldogger/record.rb +51 -0
- data/lib/bulldogger/redactor.rb +30 -0
- data/lib/bulldogger/rspec.rb +7 -0
- data/lib/bulldogger/run.rb +113 -0
- data/lib/bulldogger/version.rb +5 -0
- data/lib/bulldogger.rb +133 -0
- data/skills/bulldogger/SKILL.md +37 -0
- data/skills/bulldogger/references/failure-evidence.md +56 -0
- data/skills/bulldogger/references/probe.md +36 -0
- data/skills/bulldogger/references/record.md +28 -0
- metadata +153 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bulldogger
|
|
6
|
+
module Record
|
|
7
|
+
# Converts an existing trace-NNN.jsonl into a SQLite database, one
|
|
8
|
+
# row per event line.
|
|
9
|
+
#
|
|
10
|
+
# sqlite3 is required here, and only inside #convert (soft
|
|
11
|
+
# require) -- never at the top of this file. The design principle
|
|
12
|
+
# is "CLI and any servers are thin adapters over the files": a
|
|
13
|
+
# live SQLite writer running alongside the JSONL writer would make
|
|
14
|
+
# SQLite a second product instead of an adapter, and requiring the
|
|
15
|
+
# gem unconditionally would add a runtime dependency the core does
|
|
16
|
+
# not carry. When sqlite3 is not installed, this returns nil
|
|
17
|
+
# rather than raising, so a caller that never asked for SQL access
|
|
18
|
+
# is never broken by its absence.
|
|
19
|
+
module SqliteConverter
|
|
20
|
+
class << self
|
|
21
|
+
def convert(jsonl_path, db_path)
|
|
22
|
+
require "sqlite3"
|
|
23
|
+
rescue LoadError => e
|
|
24
|
+
# Unconditional, not gated by BULLDOGGER_DEBUG: the contract
|
|
25
|
+
# requires an explicit signal, not a silent nil, when sqlite3
|
|
26
|
+
# is unavailable ("使えないことを明示的に伝えて nil を返す").
|
|
27
|
+
# The debug gate elsewhere in this codebase exists for the
|
|
28
|
+
# :call/:return/:raise hooks, which fire on every traced
|
|
29
|
+
# event and must stay silent by default; to_sqlite is a
|
|
30
|
+
# single explicit call a caller made on purpose, never a hot
|
|
31
|
+
# path, so warning every time costs nothing worth hiding.
|
|
32
|
+
warn("bulldogger: sqlite3 not available (#{e.class}: #{e.message}); to_sqlite returning nil")
|
|
33
|
+
nil
|
|
34
|
+
else
|
|
35
|
+
write_database(jsonl_path, db_path)
|
|
36
|
+
db_path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def write_database(jsonl_path, db_path)
|
|
42
|
+
File.delete(db_path) if File.exist?(db_path)
|
|
43
|
+
db = SQLite3::Database.new(db_path)
|
|
44
|
+
create_table(db)
|
|
45
|
+
insert_events(db, jsonl_path)
|
|
46
|
+
ensure
|
|
47
|
+
db&.close
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def create_table(db)
|
|
51
|
+
db.execute(<<~SQL)
|
|
52
|
+
CREATE TABLE events (
|
|
53
|
+
seq INTEGER,
|
|
54
|
+
event TEXT,
|
|
55
|
+
depth INTEGER,
|
|
56
|
+
path TEXT,
|
|
57
|
+
line INTEGER,
|
|
58
|
+
method TEXT,
|
|
59
|
+
payload TEXT
|
|
60
|
+
)
|
|
61
|
+
SQL
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One row per JSONL line after the header. payload keeps the
|
|
65
|
+
# full original JSON object (args/return/exception vary by
|
|
66
|
+
# event kind and do not fit fixed columns without inventing a
|
|
67
|
+
# shape the JSONL schema itself does not have); the other
|
|
68
|
+
# columns exist so a caller can filter and order with plain
|
|
69
|
+
# SQL instead of parsing payload on every row.
|
|
70
|
+
def insert_events(db, jsonl_path)
|
|
71
|
+
db.transaction do
|
|
72
|
+
File.foreach(jsonl_path).with_index do |line, index|
|
|
73
|
+
next if index.zero? # header line, not an event
|
|
74
|
+
|
|
75
|
+
row = JSON.parse(line)
|
|
76
|
+
db.execute(
|
|
77
|
+
"INSERT INTO events (seq, event, depth, path, line, method, payload) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
78
|
+
[row["seq"], row["event"], row["depth"], row["path"], row["line"], row["method"], line.chomp]
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Bulldogger
|
|
7
|
+
module Record
|
|
8
|
+
# Appends one JSON object per line to trace-NNN.jsonl, plus a
|
|
9
|
+
# header line written once at construction.
|
|
10
|
+
#
|
|
11
|
+
# The NNN is picked by scanning the run directory for existing
|
|
12
|
+
# trace-*.jsonl files, not an in-memory counter: Run (owned
|
|
13
|
+
# elsewhere) already hands out its own NNN-slug.json sequence for
|
|
14
|
+
# evidence files, and a Record session has no access to that
|
|
15
|
+
# counter -- nor should it share one, since "trace-NNN" and
|
|
16
|
+
# "NNN-slug" are different filename shapes with no shared meaning
|
|
17
|
+
# to a reader comparing NNN values across them.
|
|
18
|
+
class Writer
|
|
19
|
+
FILENAME_PATTERN = /\Atrace-(\d+)\.jsonl\z/
|
|
20
|
+
|
|
21
|
+
@reserve_mutex = Mutex.new
|
|
22
|
+
|
|
23
|
+
attr_reader :path
|
|
24
|
+
|
|
25
|
+
def initialize(run_dir:, header:)
|
|
26
|
+
@path = self.class.reserve_path(run_dir)
|
|
27
|
+
@io = File.open(@path, "w")
|
|
28
|
+
write_line(header)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def write_event(event)
|
|
32
|
+
write_line(event)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def close
|
|
36
|
+
@io.close
|
|
37
|
+
@path
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Reserves the next trace-NNN.jsonl name under a process-wide
|
|
42
|
+
# lock. Two sessions starting back to back (or on two threads)
|
|
43
|
+
# must never both see the same "no trace-*.jsonl yet" listing
|
|
44
|
+
# and pick the same NNN, so the file is created (empty) while
|
|
45
|
+
# still holding the lock -- closing the window where a second
|
|
46
|
+
# scan could run before the first session's file exists on
|
|
47
|
+
# disk. The lock only spans this rare, brief start-up step,
|
|
48
|
+
# never a per-event write.
|
|
49
|
+
def reserve_path(run_dir)
|
|
50
|
+
@reserve_mutex.synchronize do
|
|
51
|
+
existing = Dir.children(run_dir).filter_map { |name| name[FILENAME_PATTERN, 1]&.to_i }
|
|
52
|
+
next_n = (existing.max || 0) + 1
|
|
53
|
+
path = File.join(run_dir, format("trace-%03d.jsonl", next_n))
|
|
54
|
+
FileUtils.touch(path)
|
|
55
|
+
path
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def write_line(hash)
|
|
63
|
+
@io.write("#{JSON.generate(hash)}\n")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../bulldogger"
|
|
4
|
+
require_relative "record/session"
|
|
5
|
+
require_relative "record/sqlite_converter"
|
|
6
|
+
|
|
7
|
+
module Bulldogger
|
|
8
|
+
# Full, unfiltered method-call recording, as an explicit verb rather
|
|
9
|
+
# than an ambient default. Where Capture answers "what failed" from
|
|
10
|
+
# a single :raise, Record answers "what happened" across a block:
|
|
11
|
+
# every :call, :return, and :raise event in the process while the
|
|
12
|
+
# block runs, one JSON object per line. This is the heavy path
|
|
13
|
+
# AGENTS.md reserves for an explicit request -- see
|
|
14
|
+
# tasks/record.rake for the measured cost of turning it on.
|
|
15
|
+
#
|
|
16
|
+
# This is a thin facade over Session (the TracePoint subscription
|
|
17
|
+
# and JSONL writer) and SqliteConverter (the optional, offline
|
|
18
|
+
# sqlite adapter), the same shape lib/bulldogger.rb uses for
|
|
19
|
+
# Capture/Run/Evidence.
|
|
20
|
+
module Record
|
|
21
|
+
class << self
|
|
22
|
+
# Runs the block with recording on. The block runs whether or
|
|
23
|
+
# not recording actually started: an app calling Record.run must
|
|
24
|
+
# see its own code execute the same way with or without
|
|
25
|
+
# Bulldogger, so only the trace file -- never the block -- is
|
|
26
|
+
# conditional on config.enabled.
|
|
27
|
+
def run
|
|
28
|
+
session = start
|
|
29
|
+
path = nil
|
|
30
|
+
begin
|
|
31
|
+
yield
|
|
32
|
+
ensure
|
|
33
|
+
path = session.stop
|
|
34
|
+
end
|
|
35
|
+
path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# config and run_dir come from the shared Bulldogger facade
|
|
39
|
+
# (already public API), not a Record-owned copy: one process has
|
|
40
|
+
# one run directory and one kill switch, shared by failure
|
|
41
|
+
# capture, probe, and record alike.
|
|
42
|
+
def start
|
|
43
|
+
Session.new(config: Bulldogger.config, run_dir: Bulldogger.run_dir)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_sqlite(jsonl_path, db_path)
|
|
47
|
+
SqliteConverter.convert(jsonl_path, db_path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bulldogger
|
|
4
|
+
# Decides whether a name (a local variable, a Hash key) should hide
|
|
5
|
+
# its value. This check runs on the name alone, before any value is
|
|
6
|
+
# touched: calling `inspect` on a secret-shaped object and then
|
|
7
|
+
# discarding the result still risks the secret leaking (into a log,
|
|
8
|
+
# into a raised error from a hostile #inspect), so the name check
|
|
9
|
+
# must gate the inspect call, not follow it.
|
|
10
|
+
class Redactor
|
|
11
|
+
def initialize(patterns)
|
|
12
|
+
@patterns = patterns
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def redact_name?(name)
|
|
16
|
+
matches?(name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def redact_key?(key)
|
|
20
|
+
matches?(key)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def matches?(name)
|
|
26
|
+
text = name.to_s
|
|
27
|
+
@patterns.any? { |pattern| pattern.match?(text) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The one line a spec_helper.rb adds to turn Bulldogger on for RSpec.
|
|
4
|
+
# Kept separate from lib/bulldogger/integrations/rspec.rb so that file
|
|
5
|
+
# can stay organized by framework alongside minitest.rb, while this
|
|
6
|
+
# stays the short, memorable require path.
|
|
7
|
+
require_relative "integrations/rspec"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Bulldogger
|
|
7
|
+
# Owns the on-disk run directory: its lazy creation, evidence file
|
|
8
|
+
# sequence numbers, and the index.json/latest written at the end.
|
|
9
|
+
#
|
|
10
|
+
# The directory is created lazily, on first use, not at
|
|
11
|
+
# construction. A fully green test suite must never touch the
|
|
12
|
+
# filesystem -- that is what "costs nothing while tests are green"
|
|
13
|
+
# means in practice -- so nothing here may mkdir until a caller
|
|
14
|
+
# actually asks for a path to write to.
|
|
15
|
+
class Run
|
|
16
|
+
def initialize(config:)
|
|
17
|
+
@config = config
|
|
18
|
+
@dir = nil
|
|
19
|
+
@sequence = 0
|
|
20
|
+
@failures = []
|
|
21
|
+
@finished = false
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns nil when the switch is off. A kill switch earns its name
|
|
26
|
+
# only if every public path honours it, so this one refuses even
|
|
27
|
+
# though record_failure and finish already refuse on their own: a
|
|
28
|
+
# caller who reads the API and asks for the run directory directly
|
|
29
|
+
# must not be the one hole that still writes to disk.
|
|
30
|
+
def dir
|
|
31
|
+
return nil unless @config.enabled
|
|
32
|
+
|
|
33
|
+
@mutex.synchronize { ensure_dir }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def next_path(slug)
|
|
37
|
+
@mutex.synchronize do
|
|
38
|
+
ensure_dir
|
|
39
|
+
@sequence += 1
|
|
40
|
+
File.join(@dir, format("%03d-%s.json", @sequence, slug))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def record(path, test:, exception_summary:)
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@failures << {
|
|
47
|
+
"path" => File.basename(path),
|
|
48
|
+
"test" => test,
|
|
49
|
+
"exception" => exception_summary
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def finish
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
return if @finished
|
|
57
|
+
|
|
58
|
+
@finished = true
|
|
59
|
+
# A disabled switch must produce nothing, even if some other
|
|
60
|
+
# caller reached run_dir directly and already created @dir --
|
|
61
|
+
# this guard does not depend on record_failure's own refusal
|
|
62
|
+
# to touch @dir being the only path here.
|
|
63
|
+
return unless @config.enabled
|
|
64
|
+
# No @dir means record was never called: a green run. Writing
|
|
65
|
+
# an index for zero failures would create the very directory
|
|
66
|
+
# the zero-cost-when-green claim says must not exist.
|
|
67
|
+
return unless @dir
|
|
68
|
+
|
|
69
|
+
write_index
|
|
70
|
+
write_latest_symlink
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def ensure_dir
|
|
77
|
+
return @dir if @dir
|
|
78
|
+
|
|
79
|
+
@dir = File.join(base_output_dir, run_dir_name)
|
|
80
|
+
FileUtils.mkdir_p(@dir)
|
|
81
|
+
@dir
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def base_output_dir
|
|
85
|
+
path = @config.output_dir
|
|
86
|
+
File.absolute_path?(path) ? path : File.join(Dir.pwd, path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_dir_name
|
|
90
|
+
"run-#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{Process.pid}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def write_index
|
|
94
|
+
index = {
|
|
95
|
+
"schema_version" => 1,
|
|
96
|
+
"run_dir" => @dir,
|
|
97
|
+
"failures" => @failures
|
|
98
|
+
}
|
|
99
|
+
File.write(File.join(@dir, "index.json"), "#{JSON.pretty_generate(index)}\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write_latest_symlink
|
|
103
|
+
link_path = File.join(base_output_dir, "latest")
|
|
104
|
+
File.delete(link_path) if File.symlink?(link_path) || File.exist?(link_path)
|
|
105
|
+
File.symlink(@dir, link_path)
|
|
106
|
+
rescue SystemCallError, NotImplementedError
|
|
107
|
+
# Not every filesystem supports symlinks. index.json and the
|
|
108
|
+
# evidence files are the source of truth; "latest" is a
|
|
109
|
+
# convenience an agent can lose without losing any evidence.
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/bulldogger.rb
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "bulldogger/version"
|
|
4
|
+
require_relative "bulldogger/config"
|
|
5
|
+
require_relative "bulldogger/capture"
|
|
6
|
+
require_relative "bulldogger/run"
|
|
7
|
+
require_relative "bulldogger/evidence"
|
|
8
|
+
require_relative "bulldogger/probe"
|
|
9
|
+
|
|
10
|
+
# Ruby execution evidence for coding agents, as files: a failing test
|
|
11
|
+
# writes a structured snapshot of its own failure, so an agent can read
|
|
12
|
+
# runtime values instead of guessing them from source.
|
|
13
|
+
#
|
|
14
|
+
# This module is a thin facade. It wires Capture (the :raise
|
|
15
|
+
# subscription and its ring of pending snapshots), Run (the on-disk
|
|
16
|
+
# run directory), and Evidence (assembling and writing one failure's
|
|
17
|
+
# JSON) together, and holds the process-global state a test run needs
|
|
18
|
+
# -- one TracePoint, one run directory. No capture, formatting, or
|
|
19
|
+
# redaction logic lives here; see capture.rb, formatter.rb, and
|
|
20
|
+
# redactor.rb.
|
|
21
|
+
module Bulldogger
|
|
22
|
+
# Autoloaded rather than required at the top of this file, because
|
|
23
|
+
# record.rb requires this file back: Record.start reads
|
|
24
|
+
# Bulldogger.config and Bulldogger.run_dir, so `require
|
|
25
|
+
# "bulldogger/record"` on its own has to work. Requiring in both
|
|
26
|
+
# directions makes a cycle, and Ruby warns that one file will see
|
|
27
|
+
# the other half-defined. Deferring until Bulldogger::Record is first
|
|
28
|
+
# named breaks it -- by then this file has finished loading, so
|
|
29
|
+
# record.rb's require of it is a no-op.
|
|
30
|
+
autoload :Record, File.expand_path("bulldogger/record", __dir__)
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def config
|
|
34
|
+
@config ||= Config.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configure
|
|
38
|
+
yield config
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start
|
|
43
|
+
return self unless config.enabled
|
|
44
|
+
|
|
45
|
+
capture.start
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def stop
|
|
50
|
+
capture.stop
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def running?
|
|
55
|
+
capture.running?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def snapshot_for(exception)
|
|
59
|
+
capture.snapshot_for(exception)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def record_failure(exception:, test:)
|
|
63
|
+
evidence.record_failure(exception: exception, test: test)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run_dir
|
|
67
|
+
run.dir
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def finish
|
|
71
|
+
run.finish
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Watches target_strings (each "Klass#method" or "Klass.method")
|
|
75
|
+
# for the life of the block and writes one evidence file
|
|
76
|
+
# summarizing every call's argument/return shape. See
|
|
77
|
+
# lib/bulldogger/probe.rb. Returns the written path, or nil if the
|
|
78
|
+
# switch is off -- the block still runs either way, only the
|
|
79
|
+
# observing and writing are skipped.
|
|
80
|
+
def probe(*target_strings, &block)
|
|
81
|
+
Probe.call(target_strings, config: config, run: run, &block)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Explicit-session counterpart to #probe: call session.finish to
|
|
85
|
+
# stop watching and write the evidence file. Returns nil, not a
|
|
86
|
+
# session, when the switch is off -- callers must guard the same
|
|
87
|
+
# way every other disabled-switch return value here is guarded.
|
|
88
|
+
def probe_start(*target_strings)
|
|
89
|
+
Probe.start(target_strings, config: config, run: run)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def probe_compare(path_a, path_b)
|
|
93
|
+
Probe.compare(path_a, path_b)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Traces every call, return, and raise in the block to a JSONL
|
|
97
|
+
# file. See lib/bulldogger/record.rb. Returns the written path, or
|
|
98
|
+
# nil if the switch is off. This is the expensive verb, which is
|
|
99
|
+
# why it is a verb: it costs roughly 4500ns per traced call, on
|
|
100
|
+
# every call rather than on a named few.
|
|
101
|
+
def record(&block)
|
|
102
|
+
Record.run(&block)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Explicit-session counterpart to #record: call session.stop to
|
|
106
|
+
# finish the trace and get its path.
|
|
107
|
+
def record_start
|
|
108
|
+
Record.start
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Converts a written trace into SQLite for indexed querying.
|
|
112
|
+
# Returns nil when the sqlite3 gem is absent, which is the normal
|
|
113
|
+
# case -- JSONL is the canonical format and this is an adapter over
|
|
114
|
+
# it, so bulldogger never depends on sqlite3 at runtime.
|
|
115
|
+
def trace_to_sqlite(jsonl_path, db_path)
|
|
116
|
+
Record.to_sqlite(jsonl_path, db_path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def capture
|
|
122
|
+
@capture ||= Capture.new(config: config)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def run
|
|
126
|
+
@run ||= Run.new(config: config)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def evidence
|
|
130
|
+
@evidence ||= Evidence.new(config: config, run: run, capture: capture)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: bulldogger
|
|
3
|
+
description: Use bulldogger failure evidence, targeted probes, and full records to inspect Ruby runtime behavior or verify a code change.
|
|
4
|
+
compatibility: Uses files from bulldogger 0.1.0. The query examples require jq. SQLite conversion requires the optional sqlite3 gem.
|
|
5
|
+
license: MIT
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Use bulldogger evidence
|
|
9
|
+
|
|
10
|
+
Choose the smallest evidence source that answers the question:
|
|
11
|
+
|
|
12
|
+
- Read an existing failure file when test output has `bulldogger evidence:`.
|
|
13
|
+
- Use `probe` for one method or for a before-and-after behavior check.
|
|
14
|
+
- Use `record` when you must follow the full call sequence.
|
|
15
|
+
|
|
16
|
+
Use the available evidence before you add logging or infer values from source.
|
|
17
|
+
|
|
18
|
+
Read [failure evidence](references/failure-evidence.md) for snapshot modes, frames, limits, and missing values.
|
|
19
|
+
Read [probe evidence](references/probe.md) for method shapes, raised exits, samples, and comparisons.
|
|
20
|
+
Read [record traces](references/record.md) for JSONL events, limits, and queries.
|
|
21
|
+
|
|
22
|
+
`probe` and `record` are explicit, expensive verbs for one focused run.
|
|
23
|
+
The environment disable switches stop all three approaches.
|
|
24
|
+
|
|
25
|
+
## Start from a failure
|
|
26
|
+
|
|
27
|
+
1. Find the `bulldogger evidence:` line in the test output.
|
|
28
|
+
2. Open the absolute JSON path from that line.
|
|
29
|
+
3. Read `capture_mode` before you inspect `frames`.
|
|
30
|
+
|
|
31
|
+
If the output is unavailable, inspect `tmp/bulldogger/latest/index.json`.
|
|
32
|
+
Each `failures[].path` value is relative to the run directory.
|
|
33
|
+
If no evidence exists, check for `BULLDOGGER_DISABLE=1` or `BULLDOGGER_DISABLED=1`.
|
|
34
|
+
Either switch prevents file output, so no `frames_unavailable_reason` exists.
|
|
35
|
+
|
|
36
|
+
Read `capture_mode` before you infer what the file can show.
|
|
37
|
+
Then follow the failure reference.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Read failure evidence
|
|
2
|
+
|
|
3
|
+
## Interpret the capture mode
|
|
4
|
+
|
|
5
|
+
- `capture_frames`: Each retained frame has `locals` and `self`.
|
|
6
|
+
- `degraded`: Frame 0 has `locals` and `self`.
|
|
7
|
+
Later frames have `locals_unavailable: true` and location data.
|
|
8
|
+
- `missed`: `frames` is empty, and `frames_unavailable_reason` explains the capture failure.
|
|
9
|
+
|
|
10
|
+
In degraded evidence, a missing `locals` field does not describe the Ruby frame.
|
|
11
|
+
It means the capture source could not read that frame's locals.
|
|
12
|
+
|
|
13
|
+
Add `gem "debug", group: :test` to the application Gemfile for complete frame locals.
|
|
14
|
+
Then install the bundle and run the failed test again.
|
|
15
|
+
|
|
16
|
+
Missed evidence still contains the test, exception message, and exception backtrace.
|
|
17
|
+
Use `frames_unavailable_reason` to distinguish disabled capture, an uncaptured exception, and ring eviction.
|
|
18
|
+
The values are `capture_disabled`, `not_captured`, and `evicted`.
|
|
19
|
+
|
|
20
|
+
## Find the useful frame
|
|
21
|
+
|
|
22
|
+
Frame 0 is the raise site.
|
|
23
|
+
For assertion failures, framework assertion code can occupy the first frames.
|
|
24
|
+
A generated Minitest failure placed the test method at index 2.
|
|
25
|
+
|
|
26
|
+
List the frame labels and paths:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
jq '[.frames[] | {index, label, path}]' /absolute/path/to/evidence.json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Choose the application or test frame that owns the values you need.
|
|
33
|
+
Then read its locals:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
jq '.frames[2].locals' /absolute/path/to/evidence.json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Find one local across all captured frames:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
jq '[.frames[] | select(.locals.qty) | {index, label, qty: .locals.qty}]' /absolute/path/to/evidence.json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Read missing values correctly
|
|
46
|
+
|
|
47
|
+
A local with `redacted: true` was hidden because its name matched a redaction pattern.
|
|
48
|
+
It has no `value` field.
|
|
49
|
+
|
|
50
|
+
A value with `truncated: true` was cut to the configured character limit.
|
|
51
|
+
Use `original_length` to see the length before truncation.
|
|
52
|
+
|
|
53
|
+
`locals_omitted` and `frames_omitted` give the numbers removed by capture limits.
|
|
54
|
+
An Array or Hash ending in `…` had more than 10 elements.
|
|
55
|
+
|
|
56
|
+
See [`docs/evidence-schema.md`](../../../docs/evidence-schema.md) for each field and more queries.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Use a targeted probe
|
|
2
|
+
|
|
3
|
+
Use a probe when one method defines the behavior you need to inspect.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
path = Bulldogger.probe("Billing::Invoice#amount") do
|
|
7
|
+
run_related_test
|
|
8
|
+
end
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Read `methods` by target name.
|
|
12
|
+
Each target has call counts, parameters, argument shapes, return shapes, raised exits, and callers.
|
|
13
|
+
|
|
14
|
+
`raised_exits` counts calls that left through an exception.
|
|
15
|
+
Those calls do not increase the return `nil_count`.
|
|
16
|
+
The distinction prevents a raised exit from appearing as a normal `nil` return.
|
|
17
|
+
|
|
18
|
+
The class, `nil`, and caller counts include every observed call.
|
|
19
|
+
The `samples` arrays contain the first `limits.max_samples` values.
|
|
20
|
+
`samples_omitted` counts later values that were not serialized.
|
|
21
|
+
|
|
22
|
+
Run the same probe before and after a change:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
before_path = Bulldogger.probe("Billing::Invoice#amount") { run_related_test }
|
|
26
|
+
# Apply the change.
|
|
27
|
+
after_path = Bulldogger.probe("Billing::Invoice#amount") { run_related_test }
|
|
28
|
+
comparison = Bulldogger.probe_compare(before_path, after_path)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`comparison["identical"] == true` shows behavior preservation for the compared shape.
|
|
32
|
+
Read each item in `differences` when the value is false.
|
|
33
|
+
|
|
34
|
+
Redacted samples have no value.
|
|
35
|
+
Truncated samples include their original length.
|
|
36
|
+
See [`docs/evidence-schema.md`](../../../docs/evidence-schema.md) for all probe fields.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Read a full record
|
|
2
|
+
|
|
3
|
+
Use a record when the relevant method is unknown or the full call sequence matters.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
path = Bulldogger.record do
|
|
7
|
+
run_related_test
|
|
8
|
+
end
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The JSONL first line is a header.
|
|
12
|
+
It gives the schema version, event set, start time, and limits.
|
|
13
|
+
Each later line is one call, return, or raise event.
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
head -n 1 "$path" | jq '{schema_version, kind, events, limits}'
|
|
17
|
+
jq -c 'select(.event) | {seq, depth, event, method}' "$path"
|
|
18
|
+
jq -c 'select(.event == "raise") | {method, exception}' "$path"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
A return with `raised: true` represents an exception exit.
|
|
22
|
+
It has no `return` field.
|
|
23
|
+
The trace does not contain `:line`, `:b_call`, or `:rescue` lines.
|
|
24
|
+
|
|
25
|
+
Arguments and return values use redaction and length limits.
|
|
26
|
+
Read `redacted`, `truncated`, and a trailing `…` before you infer that a value did not occur.
|
|
27
|
+
|
|
28
|
+
See [`docs/trace-schema.md`](../../../docs/trace-schema.md) for all event fields and tested queries.
|