Class: Rigor::Effects::Snapshot

Inherits:
Object
  • Object
show all
Defined in:
lib/rigor/effects/snapshot.rb

Overview

The committed effect snapshot — .rigor-effects.yml, the primary validation mode (ADR-103 WD7 / WD14, design note § 9.4).

It is db/schema.rb for effects: a generated artefact a project commits, whose diff in a pull request is the review signal, whose freshness CI checks, and where intent is expressed by committing the regenerated file rather than by annotating code. It emits no diagnostic and never enters rigor check's stream (ADR-102).

Two tables, chosen so a diff is attributable:

  • methods: holds each unit's direct summary — origins in the method's own body (block literals included) plus catalogued and attributed callees, but never a project callee, which is an edge. An entry therefore moves only when its own lines, the catalogue or an attribution moved.
  • reach: holds the transitive footprint at the entry points effects.snapshot.reach: names. A leaf change fans out here, and the fan-out is the information (blast radius).

The record is undischarged: tolerated: is consulted at judgment time by SnapshotDiff, never while writing, so update --no-tolerated-effects and update write byte-identical files and a policy change diffs the config rather than the record.

The serialisation is a hand-rolled JSON-compatible YAML subset: string keys, JSON scalars, flow sequences, sorted keys and sorted labels, no anchors, no tags, no timestamps. YAML.safe_load of the file round-trips through JSON unchanged, which is the property a sibling implementation reads it by.

Defined Under Namespace

Classes: Entry, ParseError

Constant Summary collapse

SCHEMA =

Bumped when the file's shape changes in a way an older reader would misread. A bump makes every existing file a regeneration event rather than a silent reinterpretation.

1
HEADER =
"# .rigor-effects.yml — generated by `rigor effects update`. Commit it; review its diff."

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(header:, methods: {}, reach: {}) ⇒ Snapshot

Returns a new instance of Snapshot.



301
302
303
304
305
306
# File 'lib/rigor/effects/snapshot.rb', line 301

def initialize(header:, methods: {}, reach: {})
  @header = header.freeze
  @methods = sorted(methods)
  @reach = sorted(reach)
  freeze
end

Instance Attribute Details

#headerObject (readonly)

Returns the value of attribute header.



299
300
301
# File 'lib/rigor/effects/snapshot.rb', line 299

def header
  @header
end

#methodsObject (readonly)

Returns the value of attribute methods.



299
300
301
# File 'lib/rigor/effects/snapshot.rb', line 299

def methods
  @methods
end

#reachObject (readonly)

Returns the value of attribute reach.



299
300
301
# File 'lib/rigor/effects/snapshot.rb', line 299

def reach
  @reach
end

Class Method Details

.build(table:, configuration:, sources: {}, full: false, registry: Registry.default, project_root: Dir.pwd) ⇒ Object

Builds the snapshot a run's effect table describes.

Parameters:

  • table (EffectTable)

    the propagated graph

  • configuration (Rigor::Configuration)

    supplies the reach globs and the digested block

  • sources (Hash{String=>Array<String>}) (defaults to: {})

    Runner#effect_sources — where each unit is defined

  • full (Boolean) (defaults to: false)

    keep the rows #omit? drops

  • registry (Registry) (defaults to: Registry.default)

    the vocabulary whose version the header carries

  • project_root (String) (defaults to: Dir.pwd)

    what sources paths are relativised against for glob matching



102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/rigor/effects/snapshot.rb', line 102

def build(table:, configuration:, sources: {}, full: false, registry: Registry.default,
          project_root: Dir.pwd)
  globs = expand_reach(configuration.effects_snapshot_reach)
  new(
    header: {
      "schema" => SCHEMA,
      "rigor" => Rigor::VERSION,
      "vocabulary" => registry.vocabulary_version,
      "config_digest" => config_digest(configuration)
    },
    methods: build_methods(table, full: full),
    reach: build_reach(table, globs, sources, project_root, full: full)
  )
end

.config_digest(configuration) ⇒ Object

The effects: block of .rigor.yml, canonicalised (keys sorted at every depth, rendered as JSON) and hashed. A Rigor upgrade, a vocabulary bump or a tolerated: edit is therefore a visible regeneration event rather than a silent reinterpretation of the record.

The implementation lives in Identity because the effects CACHE identity (#382) digests the same block: a header that agreed with the cache key only by convention would eventually stop agreeing, and the failure would be a silently reused stale sidecar.



151
152
153
# File 'lib/rigor/effects/snapshot.rb', line 151

def config_digest(configuration)
  Identity.config_digest(configuration)
end

.expand_reach(entries) ⇒ Object

Resolves effects.snapshot.reach: entries to file globs: a preset name becomes the globs it stands for, a glob stays itself.

This is where an unknown preset name is caught (#387). Configuration cannot: presets are named by plugins, and plugins load from the configuration being validated. By the time a snapshot is built the registered set is complete, so a name nothing registered is a real error — most often reach: [rails] in a project that never listed the Rails plugins — and saying so beats a reach: table that comes back mysteriously empty.



163
164
165
166
167
168
169
# File 'lib/rigor/effects/snapshot.rb', line 163

def expand_reach(entries)
  entries.flat_map do |entry|
    next [entry] if EntryPoints.glob?(entry)

    EntryPoints.resolve!(entry)
  end.uniq.sort.freeze
end

.load(path) ⇒ Object



184
185
186
# File 'lib/rigor/effects/snapshot.rb', line 184

def load(path)
  parse(File.read(path, encoding: "UTF-8"))
end

.parse(text) ⇒ Object



171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/rigor/effects/snapshot.rb', line 171

def parse(text)
  data = YAML.safe_load(text)
  raise ParseError, "not a mapping" unless data.is_a?(Hash)

  new(
    header: parse_header(data),
    methods: parse_table(data["methods"], "methods"),
    reach: parse_table(data["reach"], "reach")
  )
rescue Psych::Exception => e
  raise ParseError, e.message
end

.undischarged_index(snapshot:, table:, discharge:) ⇒ Hash?

The per-origin discharge judgment's input, for the side of a comparison that HAS origins (#385). {table => {symbol => [label]}}: for each row this snapshot carries, the labels that survive effects.tolerated: applied bundle by bundle — direct bundles under methods:, the transitive lane under reach:, matching what each table records.

It is built beside the snapshot and never inside it: the file stays flat and undischarged, and this index exists only for the duration of one comparison (Rigor::Effects::SnapshotDiff).

Parameters:

  • snapshot (Snapshot)

    the current side, already built

  • table (EffectTable)

    the run that produced it

  • discharge (Discharge)

    the policy

Returns:

  • (Hash, nil)

    nil when the policy discharges nothing, which is the common case



129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/rigor/effects/snapshot.rb', line 129

def undischarged_index(snapshot:, table:, discharge:)
  return nil if discharge.inert?

  {
    "methods" => snapshot.methods.keys.filter_map do |key|
      entry = table[key]
      [key, discharge.undischarged(entry.direct.bundles).to_a] if entry
    end.to_h,
    "reach" => snapshot.reach.keys.filter_map do |key|
      entry = table[key]
      [key, entry.undischarged.to_a] if entry
    end.to_h
  }.freeze
end

Instance Method Details

#==(other) ⇒ Object Also known as: eql?



308
309
310
# File 'lib/rigor/effects/snapshot.rb', line 308

def ==(other)
  other.is_a?(Snapshot) && other.header == @header && other.methods == @methods && other.reach == @reach
end

#hashObject



313
314
315
# File 'lib/rigor/effects/snapshot.rb', line 313

def hash
  [self.class, @header, @methods, @reach].hash
end

#table(name) ⇒ Object

The table a diff category names, so SnapshotDiff walks the two by name.



318
319
320
# File 'lib/rigor/effects/snapshot.rb', line 318

def table(name)
  name == "reach" ? @reach : @methods
end

#to_hObject

The wire form: plain Hashes, Strings, Integers, Booleans and Arrays — nothing a JSON parser would choke on and nothing a YAML reader has to resolve a tag for.



324
325
326
327
328
329
# File 'lib/rigor/effects/snapshot.rb', line 324

def to_h
  @header.merge(
    "methods" => @methods.transform_values(&:to_h),
    "reach" => @reach.transform_values(&:to_h)
  )
end

#to_yamlObject Also known as: to_s



353
354
355
356
357
358
359
360
361
362
363
# File 'lib/rigor/effects/snapshot.rb', line 353

def to_yaml
  lines = [HEADER]
  lines << "schema: #{@header.fetch('schema')}"
  lines << "rigor: #{scalar(@header.fetch('rigor'))}"
  lines << "vocabulary: #{@header.fetch('vocabulary')}"
  lines << "config_digest: #{scalar(@header.fetch('config_digest'))}"
  render_table(lines, "methods", @methods)
  lines.concat(EMPTY_REACH_NOTE) if @reach.empty?
  render_table(lines, "reach", @reach)
  "#{lines.join("\n")}\n"
end