rails_mind 0.3.1 → 0.3.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 902c75e77dcb5d76b75de68e62e73fe7a92f38ec05f621084bc6a61c047cb186
4
- data.tar.gz: b499762228b099f7d940a3b4aa58ba1d1c73d4a9c53958a02e379982fa08b28b
3
+ metadata.gz: 9306b38b60c315db82ad49a0d338c625396362d4cb738f1b26639719c1805aab
4
+ data.tar.gz: 4a8b8e5e00a4b4141d8c95e0fcaf06cd1bb9b6e99980dc0c3a7248fb772c4dc8
5
5
  SHA512:
6
- metadata.gz: ae733243113a0206980e2b34f2545f5648c00b4e032d32ae2727bd8b5da85d67067970cc73ff1654eef1eb93782457d71cb65c45e9a6bfcde5f6f7fc03985310
7
- data.tar.gz: 48d196c72e82519469bc6afba1e47a585012a9ff3f0812c243ddd9b00378f6512f5b7d4f8d62fbd5158a029e2166d35e8d3373588fc3458c87c324848d477ed2
6
+ metadata.gz: 5cae059308e349dc76067a47da37b21f2d11b6bc62b3690f5669187aa9663500aa3f4df0f39a4e1648489808263861223f12f82aaef7df161881d8c7f1710dd8
7
+ data.tar.gz: 5367481af0017d310b6b5de0bf82e29ecf4d734e366c03fbf2562033e71475eb976cd9d99f99d4c2634067350f22f078f6a2c0dbb760a5711f03c7833949fef6
data/CHANGELOG.md CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog
2
2
 
3
- ## 0.3.1 — unreleased
3
+ ## 0.3.2 — 2026-10-13
4
+
5
+ Security fix for the scrubbing 0.3.1 turned on by default. Upgrade from 0.3.1.
6
+
7
+ - An `Authorization` header in a message or string (`Bearer`, `Basic`, `Token` and other schemes; any case; `:`, `=` or `=>`; quoted, unquoted or wrapped in an array as `Net::HTTP#to_hash` prints it; `Proxy-Authorization` too) loses its credential. A `Digest` or `AWS4-HMAC-SHA256` header loses its whole value. 0.3.1 replaced only the scheme word and sent the credential after it.
8
+ - Userinfo goes from a URL of any scheme (`redis://:password@`, `rediss://`, `amqp://`, `postgres://`, `mysql2://`, `mongodb+srv://` and others), not only `http(s)://`, up to the last `@` before the path, so an unencoded `@` in a password leaves nothing behind.
9
+ - Provider credentials go without a key word next to them: GitHub tokens (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_`), AWS access key IDs (`AKIA`, `ASIA`), Stripe secret and restricted keys (`sk_live_`, `sk_test_`, `rk_…`), Slack tokens (`xox…-`), `sk-` API keys and JWTs.
10
+ - Card numbers (13 to 19 digits, grouped by spaces or dashes or not, passing the Luhn check, also next to an expiry or another number) and US social security numbers (`123-45-6789`) go from free text: messages, backtraces, event names and properties. Identity, correlation and release values (`user_id`, `account_id`, `visitor_id`, `request_id`, `release` and the like) are left alone, so a numeric account ID is never mistaken for a card. In printed hashes, `phone` and `ssn` values go too.
11
+ - Scrubbing reads at most 8 KB of a string, cut at whitespace, and every pass is linear, so a hostile message cannot stall the request that raised it. What is sent is still at most 1024 characters.
12
+ - A PostgreSQL literal ending in a backslash no longer lets the next literal through, in query shapes or database error messages. Rails runs PostgreSQL with `standard_conforming_strings`, where `\` escapes a quote only inside `E'…'`, while MySQL lets it escape one; a statement or message containing a backslash is now read both ways and anything either reading takes for a literal goes. Statements without a backslash keep their fingerprints; one with an ambiguous `\'` keeps its text only up to that literal, so its fingerprint changes.
13
+ - Tests: an adversarial corpus of these inputs that no secret may survive, as a message, an event property or a query shape.
14
+ - `rails_mind:doctor` now exits 1 and names `RAILS_MIND_KEY` or the endpoint when collection would be off.
15
+
16
+ ## 0.3.1 — 2026-10-11
4
17
 
5
18
  - Send exception messages by default, scrubbed like every other string (emails, credentials, URL queries), and further for what generic scrubbing misses: database errors lose the failing row, key values, rejected input and quoted SQL literals, and printed hashes or keyword arguments lose the values of sensitive keys. `config.capture_exception_message = false` restores the placeholder. The flag is still not validated, so an app that assigned it from ENV boots as before.
6
19
  - Send the shape of each repeated query next to its fingerprint: the normalized SQL with literals replaced by `?`, comments removed, and at most 400 characters and 900 bytes, only for fingerprints that repeated within the request. One pass handles doubled and backslash-escaped quotes, `E''` and `$$` strings, hex numbers, comments containing quotes, a literal left open where a long statement is cut, and MySQL's double-quoted strings. `config.capture_query_shapes = false` sends fingerprints only.
data/README.md CHANGED
@@ -19,7 +19,6 @@ and [release notes](CHANGELOG.md).
19
19
  bundle install
20
20
  bin/rails generate rails_mind:install --plan
21
21
  bin/rails generate rails_mind:install
22
- bin/rails rails_mind:doctor
23
22
  ```
24
23
 
25
24
  Set **`RAILS_MIND_KEY`** for the destination application environment. It is the only
@@ -29,7 +28,14 @@ app's deployed `REVISION` file; telemetry still works when none is available.
29
28
  `RAILS_MIND_ENDPOINT` and `RAILS_MIND_RELEASE` are optional overrides. See
30
29
  [configuration and release detection](docs/sdk.md#configuration-and-release-detection).
31
30
 
32
- After setting the key, run `bin/rails rails_mind:verify` on the app's
31
+ With the key in the app's environment, check the effective settings. Until the
32
+ key is set, doctor names it and exits non-zero.
33
+
34
+ ```sh
35
+ bin/rails rails_mind:doctor
36
+ ```
37
+
38
+ Next, run `bin/rails rails_mind:verify` on the app's
33
39
  server. Match its check ID in RailsMind **Setup** and wait for **Check processed**.
34
40
  Then visit the app to confirm real traffic. This command sends one labeled
35
41
  diagnostic check, consumes one event of quota, and leaves business/performance
data/docs/sdk.md CHANGED
@@ -16,6 +16,13 @@ gem "rails_mind", "~> 0.3.0"
16
16
  bundle install
17
17
  bin/rails generate rails_mind:install --plan
18
18
  bin/rails generate rails_mind:install
19
+ ```
20
+
21
+ Then set `RAILS_MIND_KEY` ([configuration](#configuration-and-release-detection)) and, with
22
+ it in the app's environment, check the effective settings. Until the key is set,
23
+ doctor names it and exits non-zero.
24
+
25
+ ```sh
19
26
  bin/rails rails_mind:doctor
20
27
  ```
21
28
 
@@ -66,7 +73,9 @@ When upgrading an existing generated initializer, remove the old endpoint/releas
66
73
  assignments that directly read `ENV` so they do not overwrite the new defaults
67
74
  with `nil` or bypass platform detection. The installer preserves existing files.
68
75
  The doctor reports whether the effective endpoint, key, and release are present,
69
- without printing credential values or contacting the server.
76
+ without printing credential values or contacting the server. While the key is
77
+ missing or the endpoint is blank, it names the setting after the report and
78
+ exits non-zero, as `rails_mind:verify` does.
70
79
 
71
80
  ## Verify the connection
72
81
 
@@ -36,5 +36,17 @@ module RailsMind
36
36
  "No network check is performed and credential values are never printed." ]
37
37
  }
38
38
  end
39
+
40
+ # What keeps collection off, one sentence each, naming the setting to change.
41
+ def problems
42
+ [].tap do |found|
43
+ if @config.token.to_s.strip.empty?
44
+ found << "RAILS_MIND_KEY is not set, so collection is off. Set it in this app's environment to the ingest key from RailsMind Setup, restart, then run bin/rails rails_mind:verify."
45
+ end
46
+ if @config.endpoint.to_s.strip.empty?
47
+ found << "The RailsMind endpoint is blank, so collection is off. Leave config.endpoint and RAILS_MIND_ENDPOINT unset to use #{Configuration::DEFAULT_ENDPOINT}, or set your RailsMind instance's URL."
48
+ end
49
+ end
50
+ end
39
51
  end
40
52
  end
@@ -113,17 +113,71 @@ module RailsMind
113
113
  # was cut), numbers and hex literals become ?, and comments go. Double
114
114
  # quotes are identifiers, except on MySQL, where they can be strings.
115
115
  LEXEME = /(?<!\w)[Ee]'(?:[^'\\]|\\.|'')*'?|'(?:[^'\\]|\\.|'')*'?|\$(\w*)\$.*?(?:\$\1\$|\z)|"(?:[^"\\]|\\.|"")*"?|`[^`]*`?|--[^\n]*|\/\*.*?(?:\*\/|\z)|\b0[xX]\h+\b|\b\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\b/m
116
+ # The same lexemes where a backslash escapes nothing outside E'...', as on
117
+ # PostgreSQL (Rails turns standard_conforming_strings on) and SQLite. Which
118
+ # rule applies is not always known (MySQL can turn backslash escapes off),
119
+ # so a statement with a backslash is read both ways and anything either
120
+ # reading takes for a literal goes. Without a backslash both agree.
121
+ STANDARD_LEXEME = /(?<!\w)[Ee]'(?:[^'\\]|\\.|'')*'?|'(?:[^']|'')*'?|\$(\w*)\$.*?(?:\$\1\$|\z)|"(?:[^"]|"")*"?|`[^`]*`?|--[^\n]*|\/\*.*?(?:\*\/|\z)|\b0[xX]\h+\b|\b\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\b/m
116
122
 
117
123
  def self.normalize(sql, mysql: false)
118
- sql.scrub("?").gsub(LEXEME) do |token|
119
- case token[0]
120
- when "-", "/" then " "
121
- when "`" then token
122
- when '"' then mysql ? "?" : token
123
- else "?"
124
+ sql = sql.scrub("?")
125
+ text = sql.include?("\\") ? read_both_ways(sql, mysql) : sql.gsub(LEXEME) { |token| replacement(token, mysql) }
126
+ text.gsub(/\s+/, " ").strip
127
+ end
128
+
129
+ def self.replacement(token, mysql)
130
+ case token[0]
131
+ when "-", "/" then " "
132
+ when "`" then token
133
+ when '"' then mysql ? "?" : token
134
+ else "?"
135
+ end
136
+ end
137
+
138
+ # Literal spans from either reading merge into one ?; a comment that
139
+ # overlaps one joins it. Kept identifiers stay only where no reading saw a
140
+ # literal.
141
+ def self.read_both_ways(sql, mysql)
142
+ literals, comments = [], []
143
+ [ STANDARD_LEXEME, LEXEME ].each do |pattern|
144
+ sql.scan(pattern) do
145
+ token = Regexp.last_match
146
+ case replacement(token[0], mysql)
147
+ when "?" then literals << token.offset(0)
148
+ when " " then comments << token.offset(0)
149
+ end
150
+ end
151
+ end
152
+ literals = merge(literals)
153
+ loop do
154
+ # Merged literal spans are sorted and disjoint, so their ends are too.
155
+ joining, comments = comments.partition do |start, finish|
156
+ overlap = literals.bsearch { |_, last| last > start }
157
+ overlap && overlap[0] < finish
124
158
  end
125
- end.gsub(/\s+/, " ").strip
159
+ break if joining.empty?
160
+ literals = merge(literals + joining)
161
+ end
162
+ spans = (literals.map { |span| span + [ "?" ] } + merge(comments).map { |span| span + [ " " ] }).sort
163
+ output, position = +"", 0
164
+ spans.each do |start, finish, mark|
165
+ output << sql[position...start] << mark
166
+ position = finish
167
+ end
168
+ output << sql[position..]
169
+ end
170
+
171
+ def self.merge(spans)
172
+ spans.sort.each_with_object([]) do |(start, finish), merged|
173
+ if merged.any? && start < merged.last[1]
174
+ merged.last[1] = [ merged.last[1], finish ].max
175
+ else
176
+ merged << [ start, finish ]
177
+ end
178
+ end
126
179
  end
180
+ private_class_method :replacement, :read_both_ways, :merge
127
181
 
128
182
  class << self
129
183
  def install!
@@ -2,13 +2,22 @@ module RailsMind
2
2
  class Redactor
3
3
  FILTER = /password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization|cookie|credential|session|email|phone|address|credit|card|body|params|arguments|bind|sql|db\.statement|query\.text/i
4
4
  MAX_STRING = 1024
5
+ # Scrubbing reads at most this much of a string; what it sends is still at
6
+ # most MAX_STRING. Every pass below is linear, and the cap bounds the rest.
7
+ INPUT_LIMIT = 8192
8
+ # Identity, correlation and release values are not free text: a numeric
9
+ # account ID that passes the Luhn check is not a card number.
10
+ PLAIN_FIELDS = %w[id kind occurred_at status release trace_id span_id parent_span_id request_id job_id visitor_id user_id account_id].freeze
5
11
 
6
12
  def call(value, depth = 0)
7
13
  return "[TRUNCATED]" if depth > 5
8
14
  case value
9
15
  when Hash
10
16
  value.first(50).to_h.transform_keys { |key| key.to_s[0, 100] }.each_with_object({}) do |(key, item), clean|
11
- clean[key] = key.match?(FILTER) ? "[FILTERED]" : call(item, depth + 1)
17
+ clean[key] = if key.match?(FILTER) then "[FILTERED]"
18
+ elsif depth.zero? && PLAIN_FIELDS.include?(key) && (item.is_a?(String) || item.is_a?(Symbol)) then scrub(item.to_s, free_text: false)
19
+ else call(item, depth + 1)
20
+ end
12
21
  end
13
22
  when Array then value.first(50).map { |item| call(item, depth + 1) }
14
23
  when String then scrub(value)
@@ -19,30 +28,55 @@ module RailsMind
19
28
  end
20
29
  end
21
30
 
31
+ # An authorization scheme names the credential that follows it, so the
32
+ # scheme and the credential go together. Digest and AWS signature headers
33
+ # carry several credential parts, so their whole value goes.
34
+ SCHEME = "(?:bearer|basic|token|negotiate|ntlm|bot|apikey)\\s+".freeze
35
+ WHOLE_VALUE_SCHEME = "(?:digest|aws4-hmac-sha256)\\s[^\\n]*".freeze
36
+
22
37
  # Exception messages carry what generic scrubbing cannot recognize: database
23
38
  # errors quote the row, key or input that failed (and the SQL with its
24
- # literals), and Ruby prints hashes and keyword arguments with their values.
39
+ # literals), and Ruby prints hashes and keyword arguments with their values,
40
+ # header values wrapped in arrays (Net::HTTP#to_hash) included.
25
41
  DATABASE_ERROR = /\A(?:PG|Mysql2|Trilogy|SQLite3)::|StatementInvalid|RecordNotUnique|NotNullViolation|ForeignKeyViolation|ValueTooLong|RangeError\z/
26
- SENSITIVE_VALUE = /((?:"|:)?(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization|credential|cookie|session)\w*"?\s*(?:=>|:)\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s,}\])]+)/i
42
+ SENSITIVE_VALUE = /((?:"|:)?(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization|credential|cookie|session|phone|ssn)\w{0,64}"?\s*(?:=>|:)\s*(?:\[(?!FILTERED\]))?)(?:\[FILTERED\]|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|#{WHOLE_VALUE_SCHEME}|(?:#{SCHEME})?[^\s,}\])]+)/i
43
+
44
+ # PostgreSQL connections use standard_conforming_strings, where a backslash
45
+ # does not escape the quote of a '...' literal (it does inside E'...');
46
+ # MySQL lets it escape the quote. Text with a backslash is read both ways,
47
+ # and anything either reading takes for a literal goes.
48
+ STANDARD_LITERAL = /(?<!\w)[Ee]'(?:[^'\\]|\\.|'')*'?|'(?:[^']|'')*'?/
49
+ ESCAPED_LITERAL = /'(?:[^'\\]|\\.|'')*'?/
27
50
 
28
51
  def scrub_message(error)
29
- text = error.message.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
52
+ text = bounded(error.message.to_s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�"))
30
53
  text = text.gsub(SENSITIVE_VALUE, '\\1"[FILTERED]"')
31
54
  .gsub(/(Failing row contains )\([^\n]*\)/, "\\1(…)")
32
55
  .gsub(/(Key \([^)\n]*\)=)\([^\n]*\)/, "\\1(…)")
33
56
  .gsub(/(invalid input (?:syntax|value) for [^:\n]*: )"[^"\n]*"/, '\\1"…"')
34
- text = text.gsub(/'(?:[^'\\]|\\.|'')*'?/, "?") if database_error?(error)
57
+ text = mask_literals(text) if database_error?(error)
35
58
  scrub(text)
36
59
  end
37
60
 
38
- def scrub(value)
39
- value.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�")
40
- .gsub(/\b[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}\b/i, "[EMAIL]")
61
+ # Userinfo runs to the last @ before the path, so an unencoded @ in a
62
+ # password cannot leave its tail behind.
63
+ USERINFO = %r{\b([a-z][a-z0-9+.\-]{0,31}://)[^\s/]*@}i
64
+ EMAIL = /\b[A-Z0-9._%+\-]{1,128}@[A-Z0-9.\-]{1,253}\.[A-Z]{2,}\b/i
65
+ AUTHORIZATION = /((?:proxy-)?authorization["']?\s*(?:=>|[=:])\s*(?:\[(?!FILTERED\]))?\s*["']?)(?:\[FILTERED\]|#{WHOLE_VALUE_SCHEME}|(?:#{SCHEME})?[^\s,;&"']+)/i
66
+ KEY_VALUE = /((?:password|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization)\s*(?:=>|[=:])\s*)(?:#{SCHEME})?[^\s,;&]+/i
67
+ PROVIDER_TOKEN = /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_\w{22,}|(?:AKIA|ASIA)[A-Z0-9]{16}\b|[sr]k_(?:live|test)_[A-Za-z0-9]{10,}|xox[abposr]-[A-Za-z0-9-]{10,}|sk-(?:proj-|ant-)?[A-Za-z0-9_-]{20,}|eyJ[\w-]{5,512}\.[\w-]{5,}\.[\w-]*)/
68
+ SSN = /\b\d{3}([- ])\d{2}\1\d{4}\b/
69
+ DIGIT_RUN = /(?<!\w)\d(?:[ -]?\d)*(?!\w)/
70
+
71
+ def scrub(value, free_text: true)
72
+ text = bounded(value.encode("UTF-8", invalid: :replace, undef: :replace, replace: "�"))
73
+ .gsub(USERINFO, '\\1[FILTERED]@')
74
+ .gsub(EMAIL, "[EMAIL]")
75
+ .gsub(AUTHORIZATION, '\\1[FILTERED]')
41
76
  .gsub(/\bBearer\s+\S+/i, "Bearer [FILTERED]")
42
- .gsub(/((?:password|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|authorization)\s*[=:]\s*)[^\s,;&]+/i, '\\1[FILTERED]')
43
- .gsub(%r{(https?://)[^\s/@]+:[^\s/@]+@}, '\\1[FILTERED]@')
44
- .gsub(%r{(https?://[^\s?#]+)[?#][^\s]*}, '\\1')
45
- .slice(0, MAX_STRING)
77
+ .gsub(KEY_VALUE, '\\1[FILTERED]')
78
+ text = text.gsub(PROVIDER_TOKEN, "[FILTERED]").gsub(SSN, "[FILTERED]").gsub(DIGIT_RUN) { |run| mask_cards(run) } if free_text
79
+ text.gsub(%r{https?://\S+}) { |url| url[/\A[^?#]*/] }.slice(0, MAX_STRING)
46
80
  end
47
81
 
48
82
  private
@@ -50,5 +84,85 @@ module RailsMind
50
84
  def database_error?(error)
51
85
  error.class.ancestors.any? { |ancestor| ancestor.name.to_s.match?(DATABASE_ERROR) }
52
86
  end
87
+
88
+ # A cut string ends at its last whitespace, so no half token is left that a
89
+ # pass could no longer recognize.
90
+ def bounded(text)
91
+ return text if text.length <= INPUT_LIMIT
92
+ text = text[0, INPUT_LIMIT]
93
+ cut = text.rindex(/\s/)
94
+ cut ? text[0, cut] : text
95
+ end
96
+
97
+ def mask_literals(text)
98
+ return text.gsub(ESCAPED_LITERAL, "?") unless text.include?("\\")
99
+ spans = [ STANDARD_LITERAL, ESCAPED_LITERAL ].flat_map { |pattern| text.to_enum(:scan, pattern).map { Regexp.last_match.offset(0) } }
100
+ output, position = +"", 0
101
+ merge(spans).each do |start, finish|
102
+ output << text[position...start] << "?"
103
+ position = finish
104
+ end
105
+ output << text[position..]
106
+ end
107
+
108
+ def merge(spans)
109
+ spans.sort.each_with_object([]) do |(start, finish), merged|
110
+ if merged.any? && start < merged.last[1]
111
+ merged.last[1] = [ merged.last[1], finish ].max
112
+ else
113
+ merged << [ start, finish ]
114
+ end
115
+ end
116
+ end
117
+
118
+ # A card number is 13 to 19 digits, maybe in groups split by spaces or
119
+ # dashes, that pass the Luhn check. A run of digit groups can hold one next
120
+ # to other numbers (an expiry, a quantity), so every span of whole groups
121
+ # with at most 19 digits is tried, and the longest that passes goes.
122
+ def mask_cards(run)
123
+ groups = run.to_enum(:scan, /\d+/).map { Regexp.last_match.offset(0) }
124
+ digits = run.delete(" -")
125
+ return run if digits.length < 13
126
+ bounds, count = [], 0
127
+ groups.each { |start, finish| bounds << [ count, count += finish - start ] }
128
+ plain, doubled = luhn_sums(digits)
129
+ output, position, first = +"", 0, 0
130
+ while first < groups.size
131
+ longest, from = nil, bounds[first][0]
132
+ (first...groups.size).each do |last|
133
+ to = bounds[last][1]
134
+ break if to - from > 19
135
+ next if to - from < 13
136
+ parity = to % 2
137
+ sum = doubled[parity][to] - doubled[parity][from] + plain[1 - parity][to] - plain[1 - parity][from]
138
+ longest = last if (sum % 10).zero?
139
+ end
140
+ if longest
141
+ output << run[position...groups[first][0]] << "[FILTERED]"
142
+ position, first = groups[longest][1], longest + 1
143
+ else
144
+ first += 1
145
+ end
146
+ end
147
+ output << run[position..]
148
+ end
149
+
150
+ # Prefix sums of each digit, plain and Luhn-doubled, split by position
151
+ # parity. In a span ending at index `to`, Luhn doubles the digits whose
152
+ # position has the parity of `to`, so any span's checksum is four lookups.
153
+ def luhn_sums(digits)
154
+ plain, doubled = Array.new(2) { [ 0 ] }, Array.new(2) { [ 0 ] }
155
+ digits.each_char.with_index do |char, index|
156
+ value = char.ord - 48
157
+ twice = value * 2
158
+ twice -= 9 if twice > 9
159
+ [ 0, 1 ].each do |parity|
160
+ own = index % 2 == parity
161
+ plain[parity] << plain[parity].last + (own ? value : 0)
162
+ doubled[parity] << doubled[parity].last + (own ? twice : 0)
163
+ end
164
+ end
165
+ [ plain, doubled ]
166
+ end
53
167
  end
54
168
  end
@@ -1,3 +1,3 @@
1
1
  module RailsMind
2
- VERSION = "0.3.1"
2
+ VERSION = "0.3.2"
3
3
  end
@@ -7,9 +7,15 @@ namespace :rails_mind do
7
7
  exit 1 unless result[:success]
8
8
  end
9
9
 
10
- desc "Inspect dependencies and report the non-destructive integration plan"
10
+ desc "Inspect dependencies and report the non-destructive integration plan; fails while collection is off"
11
11
  task doctor: :environment do
12
12
  require "rails_mind/diagnostic"
13
- puts JSON.pretty_generate(RailsMind::Diagnostic.new(root: Rails.root).report.merge(collector: RailsMind.stats))
13
+ diagnostic = RailsMind::Diagnostic.new(root: Rails.root)
14
+ puts JSON.pretty_generate(diagnostic.report.merge(collector: RailsMind.stats))
15
+ problems = diagnostic.problems
16
+ unless problems.empty?
17
+ warn problems
18
+ exit 1
19
+ end
14
20
  end
15
21
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_mind
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - RailsMind