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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +262 -0
  4. data/docs/design-decisions.md +142 -0
  5. data/docs/evidence-schema.md +388 -0
  6. data/docs/maintenance.md +92 -0
  7. data/docs/trace-schema.md +118 -0
  8. data/lib/bulldogger/capture.rb +98 -0
  9. data/lib/bulldogger/config.rb +57 -0
  10. data/lib/bulldogger/evidence.rb +106 -0
  11. data/lib/bulldogger/formatter.rb +103 -0
  12. data/lib/bulldogger/frame_source.rb +147 -0
  13. data/lib/bulldogger/integrations/minitest.rb +87 -0
  14. data/lib/bulldogger/integrations/rspec.rb +56 -0
  15. data/lib/bulldogger/minitest.rb +7 -0
  16. data/lib/bulldogger/pending.rb +58 -0
  17. data/lib/bulldogger/probe/bucket.rb +90 -0
  18. data/lib/bulldogger/probe/comparator.rb +95 -0
  19. data/lib/bulldogger/probe/method_stats.rb +215 -0
  20. data/lib/bulldogger/probe/raise_tracker.rb +141 -0
  21. data/lib/bulldogger/probe/registry.rb +32 -0
  22. data/lib/bulldogger/probe/session.rb +159 -0
  23. data/lib/bulldogger/probe/target.rb +13 -0
  24. data/lib/bulldogger/probe/target_resolver.rb +86 -0
  25. data/lib/bulldogger/probe/writer.rb +61 -0
  26. data/lib/bulldogger/probe.rb +36 -0
  27. data/lib/bulldogger/record/session.rb +334 -0
  28. data/lib/bulldogger/record/sqlite_converter.rb +86 -0
  29. data/lib/bulldogger/record/writer.rb +67 -0
  30. data/lib/bulldogger/record.rb +51 -0
  31. data/lib/bulldogger/redactor.rb +30 -0
  32. data/lib/bulldogger/rspec.rb +7 -0
  33. data/lib/bulldogger/run.rb +113 -0
  34. data/lib/bulldogger/version.rb +5 -0
  35. data/lib/bulldogger.rb +133 -0
  36. data/skills/bulldogger/SKILL.md +37 -0
  37. data/skills/bulldogger/references/failure-evidence.md +56 -0
  38. data/skills/bulldogger/references/probe.md +36 -0
  39. data/skills/bulldogger/references/record.md +28 -0
  40. 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bulldogger
4
+ VERSION = "0.1.0"
5
+ 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.