constable-rails 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 (76) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +515 -0
  5. data/exe/constable +7 -0
  6. data/lib/constable/case.rb +336 -0
  7. data/lib/constable/cli.rb +475 -0
  8. data/lib/constable/cold_case/minitest.rb +342 -0
  9. data/lib/constable/cold_case/rspec.rb +334 -0
  10. data/lib/constable/cold_case.rb +280 -0
  11. data/lib/constable/config.rb +125 -0
  12. data/lib/constable/coverage.rb +951 -0
  13. data/lib/constable/diff.rb +212 -0
  14. data/lib/constable/dsl.rb +833 -0
  15. data/lib/constable/identity.rb +121 -0
  16. data/lib/constable/importer/modernizer.rb +860 -0
  17. data/lib/constable/importer/reopener.rb +468 -0
  18. data/lib/constable/importer.rb +51 -0
  19. data/lib/constable/investigation.rb +67 -0
  20. data/lib/constable/isolation.rb +171 -0
  21. data/lib/constable/jail.rb +399 -0
  22. data/lib/constable/log_router.rb +197 -0
  23. data/lib/constable/matchers.rb +834 -0
  24. data/lib/constable/order_audit.rb +130 -0
  25. data/lib/constable/rails_support.rb +213 -0
  26. data/lib/constable/railtie.rb +36 -0
  27. data/lib/constable/registry.rb +57 -0
  28. data/lib/constable/reporter.rb +625 -0
  29. data/lib/constable/result.rb +149 -0
  30. data/lib/constable/runner.rb +697 -0
  31. data/lib/constable/selection.rb +205 -0
  32. data/lib/constable/storage/adapter.rb +91 -0
  33. data/lib/constable/storage/mysql_adapter.rb +125 -0
  34. data/lib/constable/storage/postgres_adapter.rb +125 -0
  35. data/lib/constable/storage/sqlite_adapter.rb +84 -0
  36. data/lib/constable/storage.rb +847 -0
  37. data/lib/constable/version.rb +5 -0
  38. data/lib/constable/warrants.rb +290 -0
  39. data/lib/constable-rails.rb +16 -0
  40. data/lib/constable.rb +151 -0
  41. data/lib/generators/constable/base.rb +99 -0
  42. data/lib/generators/constable/channel/channel_generator.rb +20 -0
  43. data/lib/generators/constable/channel/templates/channel_case.rb.tt +29 -0
  44. data/lib/generators/constable/controller/controller_generator.rb +25 -0
  45. data/lib/generators/constable/controller/templates/controller_case.rb.tt +32 -0
  46. data/lib/generators/constable/generator/generator_generator.rb +31 -0
  47. data/lib/generators/constable/generator/templates/generator_case.rb.tt +28 -0
  48. data/lib/generators/constable/helper/helper_generator.rb +23 -0
  49. data/lib/generators/constable/helper/templates/helper_case.rb.tt +19 -0
  50. data/lib/generators/constable/import_generator.rb +137 -0
  51. data/lib/generators/constable/install_generator.rb +188 -0
  52. data/lib/generators/constable/integration/integration_generator.rb +27 -0
  53. data/lib/generators/constable/integration/templates/request_case.rb.tt +22 -0
  54. data/lib/generators/constable/job/job_generator.rb +20 -0
  55. data/lib/generators/constable/job/templates/job_case.rb.tt +33 -0
  56. data/lib/generators/constable/mailbox/mailbox_generator.rb +20 -0
  57. data/lib/generators/constable/mailbox/templates/mailbox_case.rb.tt +26 -0
  58. data/lib/generators/constable/mailer/mailer_generator.rb +32 -0
  59. data/lib/generators/constable/mailer/templates/mailer_case.rb.tt +34 -0
  60. data/lib/generators/constable/mailer/templates/preview.rb.tt +14 -0
  61. data/lib/generators/constable/model/model_generator.rb +31 -0
  62. data/lib/generators/constable/model/templates/model_case.rb.tt +37 -0
  63. data/lib/generators/constable/resource/resource_generator.rb +27 -0
  64. data/lib/generators/constable/scaffold/scaffold_generator.rb +42 -0
  65. data/lib/generators/constable/scaffold/templates/api_controller_case.rb.tt +54 -0
  66. data/lib/generators/constable/scaffold/templates/controller_case.rb.tt +70 -0
  67. data/lib/generators/constable/scaffold/templates/system_case.rb.tt +53 -0
  68. data/lib/generators/constable/system/system_generator.rb +20 -0
  69. data/lib/generators/constable/system/templates/system_case.rb.tt +18 -0
  70. data/lib/generators/constable/templates/authenticatable.rb.tt +31 -0
  71. data/lib/generators/constable/templates/case_helper.rb.tt +179 -0
  72. data/lib/generators/constable/templates/config.yml.tt +67 -0
  73. data/lib/generators/constable/templates/example_case.rb.tt +56 -0
  74. data/lib/generators/constable/templates/matchers.rb.tt +36 -0
  75. data/lib/generators/constable/templates/rubocop.yml.tt +12 -0
  76. metadata +209 -0
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "set"
5
+
6
+ module Constable
7
+ # The git helper -- "what changed on this beat".
8
+ #
9
+ # Two consumers, one implementation:
10
+ #
11
+ # * the Runner's git-diff test selection ("run the cases for the files I touched"),
12
+ # * the diff-based coverage gate ("hold the lines I touched to the threshold").
13
+ #
14
+ # Both ask the same question -- what is different between this working tree and the
15
+ # point where this branch left the mainline -- so both ask it here, and neither has to
16
+ # know how git spells the answer.
17
+ #
18
+ # Everything degrades to "no diff info" rather than raising: git may not be installed,
19
+ # the directory may not be a repository, the repository may have no commits yet, or the
20
+ # whole thing may be running from a tarball in CI. In every one of those cases
21
+ # `available?` is false, `changed_files` is `[]` and `changed_lines` is `{}` -- callers
22
+ # fall back to running everything, which is always the safe direction.
23
+ module Diff
24
+ # Candidate mainlines, tried in order. `origin/*` first: on a CI checkout the local
25
+ # `main` may not exist at all, and where it does it can be stale.
26
+ DEFAULT_BASES = %w[origin/main origin/master origin/HEAD main master].freeze
27
+
28
+ # git's well-known empty tree. Diffing against it makes a repository's very first
29
+ # commit read as "every line is new", which is exactly right -- on a one-commit
30
+ # repository there is no earlier state for any of it to be older than.
31
+ EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
32
+
33
+ HUNK_HEADER = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
34
+ NEW_FILE_HEADER = %r{\A\+\+\+ (?:b/)?(.+)\z}
35
+
36
+ module_function
37
+
38
+ # True when git can actually answer questions here: the binary exists, this is a
39
+ # repository, and it has at least one commit to compare against.
40
+ def available?(root: Constable.root)
41
+ !head_sha(root: root).nil?
42
+ end
43
+
44
+ # The absolute path of the repository's top level, or nil when there isn't one.
45
+ # Paths from `changed_files`/`changed_lines` are relative to *this*, not to
46
+ # `Constable.root` -- the two are usually the same directory, but an engine or a
47
+ # monorepo package can sit below the repository root.
48
+ def git_root(root: Constable.root)
49
+ out = git("rev-parse", "--show-toplevel", root: root)
50
+ out && !out.strip.empty? ? File.realpath(out.strip) : nil
51
+ rescue Errno::ENOENT
52
+ nil
53
+ end
54
+
55
+ # Every file that differs from the merge base and still exists on disk, plus every
56
+ # untracked file. Deletions are dropped: there is nothing left to test or cover.
57
+ #
58
+ # Diff.changed_files #=> ["app/models/user.rb", "test/cases/user_case.rb"]
59
+ # Diff.changed_files(absolute: true) #=> ["/repo/app/models/user.rb", ...]
60
+ # Diff.changed_files(since: "main") # explicit base instead of the detected one
61
+ #
62
+ # Returns [] when git can't answer.
63
+ def changed_files(since: nil, root: Constable.root, absolute: false)
64
+ base = resolve_base(since: since, root: root)
65
+ return [] unless base
66
+
67
+ tracked = git("diff", "--name-only", "--diff-filter=d", base, "--", root: root).to_s.lines
68
+ files = (tracked.map(&:chomp) + untracked_files(root: root)).map { |p| unquote(p) }
69
+ files = files.uniq.reject(&:empty?)
70
+ absolute ? files.map { |p| File.expand_path(p, git_root(root: root) || root) } : files
71
+ end
72
+
73
+ # The changed *line numbers* per file, as a Set of integers keyed by path:
74
+ #
75
+ # Diff.changed_lines #=> { "app/models/user.rb" => #<Set: {12, 13, 40}> }
76
+ # Diff.changed_lines(absolute: true) #=> { "/repo/app/models/user.rb" => ... }
77
+ #
78
+ # Only lines that exist in the *new* state are reported -- an added or modified line
79
+ # has a number in the file you can go and look at; a deleted line does not. Untracked
80
+ # files count as changed in their entirety, since none of their lines existed before.
81
+ #
82
+ # Returns {} when git can't answer.
83
+ def changed_lines(since: nil, root: Constable.root, absolute: false)
84
+ base = resolve_base(since: since, root: root)
85
+ return {} unless base
86
+
87
+ out = git("diff", "--unified=0", "--no-color", "--no-ext-diff", "--diff-filter=d", base, "--", root: root)
88
+ lines = parse_unified_diff(out.to_s)
89
+ base_dir = git_root(root: root) || root.to_s
90
+
91
+ untracked_files(root: root).each do |path|
92
+ full = File.expand_path(path, base_dir)
93
+ next unless File.file?(full)
94
+
95
+ count = File.readlines(full).size
96
+ (lines[path] ||= Set.new).merge(1..count) if count.positive?
97
+ end
98
+
99
+ return lines unless absolute
100
+
101
+ lines.each_with_object({}) { |(path, nums), out_hash| out_hash[File.expand_path(path, base_dir)] = nums }
102
+ end
103
+
104
+ # The revision everything is compared against, or nil when there isn't one.
105
+ #
106
+ # Order of preference:
107
+ # 1. an explicit `since:` (a branch, tag or sha -- whatever the caller passed),
108
+ # 2. the merge base with the first of DEFAULT_BASES that exists *and* isn't HEAD
109
+ # itself -- the branch-point, which is what "what did I change" means on a
110
+ # feature branch,
111
+ # 3. HEAD, when we're sitting on the mainline with uncommitted work -- the diff is
112
+ # then simply the working tree,
113
+ # 4. HEAD~1, when the tree is clean -- "what did the last commit change",
114
+ # 5. the empty tree, on a repository with a single commit and nothing else to
115
+ # compare to.
116
+ def resolve_base(since: nil, root: Constable.root)
117
+ head = head_sha(root: root)
118
+ return nil unless head
119
+
120
+ if since
121
+ resolved = rev_parse(since, root: root)
122
+ return resolved
123
+ end
124
+
125
+ DEFAULT_BASES.each do |ref|
126
+ next unless rev_parse(ref, root: root)
127
+
128
+ merge_base = git("merge-base", "HEAD", ref, root: root)&.strip
129
+ next if merge_base.nil? || merge_base.empty? || merge_base == head
130
+
131
+ return merge_base
132
+ end
133
+
134
+ return head if dirty?(root: root)
135
+
136
+ rev_parse("HEAD~1", root: root) || EMPTY_TREE
137
+ end
138
+
139
+ # True when there is uncommitted work (staged, unstaged or untracked).
140
+ def dirty?(root: Constable.root)
141
+ out = git("status", "--porcelain", root: root)
142
+ !out.nil? && !out.strip.empty?
143
+ end
144
+
145
+ def head_sha(root: Constable.root)
146
+ rev_parse("HEAD", root: root)
147
+ end
148
+
149
+ # --- internals -------------------------------------------------------------
150
+
151
+ def untracked_files(root: Constable.root)
152
+ git("ls-files", "--others", "--exclude-standard", root: root).to_s.lines.map { |l| unquote(l.chomp) }
153
+ end
154
+
155
+ def rev_parse(ref, root: Constable.root)
156
+ out = git("rev-parse", "--verify", "--quiet", "#{ref}^{commit}", root: root)
157
+ out.nil? || out.strip.empty? ? nil : out.strip
158
+ end
159
+
160
+ # Walks `git diff --unified=0` output. The `+++ b/path` line names the file in its new
161
+ # state (so renames land under the new name, which is the one on disk); each `@@`
162
+ # header carries the new-side start line and count.
163
+ def parse_unified_diff(output)
164
+ current = nil
165
+ output.each_line.with_object({}) do |line, files|
166
+ line = line.chomp
167
+
168
+ if (match = NEW_FILE_HEADER.match(line))
169
+ path = unquote(match[1])
170
+ # git spells a missing side of the diff "/dev/null" in its own output -- that
171
+ # is a marker in a text stream, not this platform's null device.
172
+ current = path == "/dev/null" ? nil : path # rubocop:disable Style/FileNull
173
+ elsif current && (match = HUNK_HEADER.match(line))
174
+ start = match[1].to_i
175
+ count = match[2] ? match[2].to_i : 1
176
+ (files[current] ||= Set.new).merge(start...(start + count)) if count.positive?
177
+ end
178
+ end
179
+ end
180
+
181
+ # git quotes paths containing anything exotic, C-string style. Unwrap the common case
182
+ # rather than pretending such paths don't exist.
183
+ def unquote(path)
184
+ return path unless path.start_with?('"') && path.end_with?('"') && path.length > 1
185
+
186
+ body = path[1..-2]
187
+ body.gsub(/\\([0-7]{3}|.)/) do
188
+ token = Regexp.last_match(1)
189
+ case token
190
+ when "n" then "\n"
191
+ when "t" then "\t"
192
+ when '"', "\\" then token
193
+ else token.match?(/\A[0-7]{3}\z/) ? token.to_i(8).chr : token
194
+ end
195
+ end.force_encoding(Encoding::UTF_8)
196
+ end
197
+
198
+ # Every shell-out funnels through here: argument array (never a shell string), a
199
+ # fixed working directory, and nil rather than an exception on any kind of failure.
200
+ def git(*args, root: Constable.root)
201
+ dir = root.to_s
202
+ return nil unless File.directory?(dir)
203
+
204
+ out, _err, status = Open3.capture3("git", *args, chdir: dir)
205
+ return nil unless status.success?
206
+
207
+ out.dup.force_encoding(Encoding::UTF_8).scrub
208
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ENOTDIR, IOError
209
+ nil
210
+ end
211
+ end
212
+ end