excavate 1.0.3 → 1.1.1

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +6 -94
  3. data/TODO.fixup/01-autoload-migration.md +65 -0
  4. data/TODO.fixup/02-redundant-external-requires.md +48 -0
  5. data/TODO.fixup/03-extractor-registry-ocp.md +150 -0
  6. data/TODO.fixup/04-filemagic-signature-struct.md +68 -0
  7. data/TODO.fixup/05-utils-silence-stream-dead-code.md +57 -0
  8. data/TODO.fixup/06-archive-filesystem-module.md +72 -0
  9. data/TODO.fixup/07-archive-targets-helper.md +62 -0
  10. data/TODO.fixup/08-archive-selection-dry.md +133 -0
  11. data/TODO.fixup/09-cab-fallback-heuristic-named.md +88 -0
  12. data/TODO.fixup/10-specs-remove-respond-to-matcher.md +38 -0
  13. data/TODO.fixup/11-specs-cover-new-collaborators.md +101 -0
  14. data/TODO.fixup/12-final-verification.md +20 -0
  15. data/TODO.fixup/README.md +73 -0
  16. data/excavate.gemspec +1 -1
  17. data/lib/excavate/archive.rb +46 -186
  18. data/lib/excavate/cli.rb +0 -2
  19. data/lib/excavate/extractors/cab_extractor.rb +2 -0
  20. data/lib/excavate/extractors/cpio_extractor.rb +3 -1
  21. data/lib/excavate/extractors/extractor.rb +72 -1
  22. data/lib/excavate/extractors/gzip_extractor.rb +4 -6
  23. data/lib/excavate/extractors/ole_extractor.rb +2 -2
  24. data/lib/excavate/extractors/rpm_extractor.rb +2 -1
  25. data/lib/excavate/extractors/seven_zip_extractor.rb +2 -2
  26. data/lib/excavate/extractors/tar_extractor.rb +2 -0
  27. data/lib/excavate/extractors/xar_extractor.rb +2 -1
  28. data/lib/excavate/extractors/xz_extractor.rb +3 -58
  29. data/lib/excavate/extractors/zip_extractor.rb +2 -0
  30. data/lib/excavate/extractors.rb +17 -11
  31. data/lib/excavate/file_magic.rb +30 -13
  32. data/lib/excavate/filesystem.rb +70 -0
  33. data/lib/excavate/nested_cab_fallback.rb +31 -0
  34. data/lib/excavate/selection.rb +64 -0
  35. data/lib/excavate/version.rb +1 -1
  36. data/lib/excavate.rb +13 -5
  37. metadata +20 -5
  38. data/lib/excavate/utils.rb +0 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6360dbc5e452e6a0984a8bb8394051fa74da9d33700afd1ca9b36dbd73f9eeeb
4
- data.tar.gz: 4d1f8e805be65dd56c6194915a43433561ba29572b016d6dc3d613617140957f
3
+ metadata.gz: b8c1a236bccd731f1a145805e3fe1288bb9536493331657ff0718e17be9f847b
4
+ data.tar.gz: c60465a7191a66dd237d4ae88e6dc631cd26c6be1e1f161f594358e4d1adcdce
5
5
  SHA512:
6
- metadata.gz: dd1b31fabaf5c27c182bfec752255f918acf63763630bd7b6e39850f8296219b0ff182458efb3cfc5b3fe04f448b657a5e8b197330d532f1ccceed22e78a7ba3
7
- data.tar.gz: f8a7aa72c8486144b9dcd09c5ecc05fa4c0acefbcc7482dca5a09cb1725d0d84438125438f1020dbdc478e37dce02486d1c4fdab7d37dc62e033a57f0094bd6b
6
+ metadata.gz: dda5395e5512553bd35c793c186042d5b64b5a72cec221d30dfad325966e88af3364c1470f7d2afbfa595e68dee90219ac0ce4279ac7301fb4efc400498ef1a8
7
+ data.tar.gz: 979eb4fe6b6af40579c9b80b77e92f5229117deca264fa2035a0cdd1dd5d28ece49d0c2082ee7fc7578439826424522c30c669e70f2f8ea468ed5ded88dce3b8
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-03-19 00:32:08 UTC using RuboCop version 1.85.1.
3
+ # on 2026-07-24 04:23:45 UTC using RuboCop version 1.85.1.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -11,7 +11,7 @@ Gemspec/RequiredRubyVersion:
11
11
  Exclude:
12
12
  - 'excavate.gemspec'
13
13
 
14
- # Offense count: 21
14
+ # Offense count: 6
15
15
  # This cop supports safe autocorrection (--autocorrect).
16
16
  # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings.
17
17
  # URISchemes: http, https
@@ -20,77 +20,12 @@ Layout/LineLength:
20
20
  - 'bin/rspec'
21
21
  - 'excavate.gemspec'
22
22
  - 'lib/excavate/extractors/rpm_extractor.rb'
23
- - 'lib/excavate/utils.rb'
24
23
  - 'spec/excavate/archive_spec.rb'
25
- - 'test_archives/memory_test.rb'
26
- - 'test_msi_memory.rb'
27
24
 
28
- # Offense count: 1
29
- # Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch.
30
- Lint/DuplicateBranch:
31
- Exclude:
32
- - 'lib/excavate/utils.rb'
33
-
34
- # Offense count: 2
35
- # This cop supports safe autocorrection (--autocorrect).
36
- Lint/ScriptPermission:
37
- Exclude:
38
- - 'test_archives/memory_test.rb'
39
- - 'test_msi_memory.rb'
40
-
41
- # Offense count: 1
42
- # This cop supports safe autocorrection (--autocorrect).
43
- # Configuration parameters: IgnoreEmptyBlocks, AllowUnusedKeywordArguments.
44
- Lint/UnusedBlockArgument:
45
- Exclude:
46
- - 'test_msi_memory.rb'
47
-
48
- # Offense count: 4
49
- # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max.
50
- Metrics/AbcSize:
51
- Exclude:
52
- - 'test_archives/memory_test.rb'
53
- - 'test_msi_memory.rb'
54
-
55
- # Offense count: 2
56
- # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
57
- Metrics/CyclomaticComplexity:
58
- Exclude:
59
- - 'test_archives/memory_test.rb'
60
- - 'test_msi_memory.rb'
61
-
62
- # Offense count: 11
25
+ # Offense count: 5
63
26
  # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
64
27
  Metrics/MethodLength:
65
- Max: 82
66
-
67
- # Offense count: 2
68
- # Configuration parameters: AllowedMethods, AllowedPatterns, Max.
69
- Metrics/PerceivedComplexity:
70
- Exclude:
71
- - 'test_archives/memory_test.rb'
72
- - 'test_msi_memory.rb'
73
-
74
- # Offense count: 2
75
- # Configuration parameters: Mode, AllowedMethods, AllowedPatterns, AllowBangMethods, WaywardPredicates.
76
- # AllowedMethods: call
77
- # WaywardPredicates: infinite?, nonzero?
78
- Naming/PredicateMethod:
79
- Exclude:
80
- - 'test_archives/memory_test.rb'
81
- - 'test_msi_memory.rb'
82
-
83
- # Offense count: 1
84
- # This cop supports safe autocorrection (--autocorrect).
85
- Performance/RegexpMatch:
86
- Exclude:
87
- - 'test_archives/memory_test.rb'
88
-
89
- # Offense count: 1
90
- # This cop supports unsafe autocorrection (--autocorrect-all).
91
- Performance/StringInclude:
92
- Exclude:
93
- - 'test_archives/memory_test.rb'
28
+ Max: 11
94
29
 
95
30
  # Offense count: 46
96
31
  # Configuration parameters: Prefixes, AllowedPatterns.
@@ -101,12 +36,12 @@ RSpec/ContextWording:
101
36
  - 'spec/excavate/cli_spec.rb'
102
37
  - 'spec/support/fresh_work_dir.rb'
103
38
 
104
- # Offense count: 11
39
+ # Offense count: 18
105
40
  # Configuration parameters: CountAsOne.
106
41
  RSpec/ExampleLength:
107
42
  Max: 16
108
43
 
109
- # Offense count: 18
44
+ # Offense count: 32
110
45
  RSpec/MultipleExpectations:
111
46
  Max: 2
112
47
 
@@ -114,26 +49,3 @@ RSpec/MultipleExpectations:
114
49
  # Configuration parameters: AllowedGroups.
115
50
  RSpec/NestedGroups:
116
51
  Max: 4
117
-
118
- # Offense count: 8
119
- # This cop supports safe autocorrection (--autocorrect).
120
- # Configuration parameters: EnforcedStyle.
121
- # SupportedStyles: trailing_conditional, ternary
122
- Style/EmptyStringInsideInterpolation:
123
- Exclude:
124
- - 'test_archives/memory_test.rb'
125
- - 'test_msi_memory.rb'
126
-
127
- # Offense count: 2
128
- # This cop supports unsafe autocorrection (--autocorrect-all).
129
- Style/IdenticalConditionalBranches:
130
- Exclude:
131
- - 'lib/excavate/utils.rb'
132
-
133
- # Offense count: 1
134
- # This cop supports safe autocorrection (--autocorrect).
135
- # Configuration parameters: EnforcedStyle.
136
- # SupportedStyles: implicit, explicit
137
- Style/RescueStandardError:
138
- Exclude:
139
- - 'test_msi_memory.rb'
@@ -0,0 +1,65 @@
1
+ # 01 — Autoload migration
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ User's global rule:
8
+
9
+ > NEVER use `require_relative` for internal library code. Never use `require`
10
+ > with a path to code within your own library. Use Ruby `autoload` instead.
11
+ > Define autoload entries in the immediate parent namespace's file — create
12
+ > that file if it doesn't exist.
13
+
14
+ Currently `lib/excavate.rb` and `lib/excavate/extractors.rb` eagerly pull in
15
+ every file with `require_relative`, defeating lazy loading and creating
16
+ implicit load-order couplings.
17
+
18
+ ## Scope
19
+
20
+ Offending `require_relative` calls inside `lib/`:
21
+
22
+ ```
23
+ lib/excavate.rb:3 require_relative "excavate/version"
24
+ lib/excavate.rb:4 require_relative "excavate/extractors"
25
+ lib/excavate.rb:5 require_relative "excavate/archive"
26
+ lib/excavate.rb:6 require_relative "excavate/file_magic"
27
+ lib/excavate.rb:7 require_relative "excavate/utils"
28
+ lib/excavate/cli.rb:3 require_relative "../excavate"
29
+ lib/excavate/extractors.rb:1 require_relative "extractors/extractor"
30
+ lib/excavate/extractors.rb:2 require_relative "extractors/cab_extractor"
31
+ lib/excavate/extractors.rb:3 require_relative "extractors/cpio_extractor"
32
+ lib/excavate/extractors.rb:4 require_relative "extractors/gzip_extractor"
33
+ lib/excavate/extractors.rb:5 require_relative "extractors/ole_extractor"
34
+ lib/excavate/extractors.rb:6 require_relative "extractors/rpm_extractor"
35
+ lib/excavate/extractors.rb:7 require_relative "extractors/seven_zip_extractor"
36
+ lib/excavate/extractors.rb:8 require_relative "extractors/tar_extractor"
37
+ lib/excavate/extractors.rb:9 require_relative "extractors/xar_extractor"
38
+ lib/excavate/extractors.rb:10 require_relative "extractors/xz_extractor"
39
+ lib/excavate/extractors.rb:11 require_relative "extractors/zip_extractor"
40
+ lib/excavate/extractors/ole_extractor.rb:5 require_relative "../file_magic"
41
+ ```
42
+
43
+ ## Plan
44
+
45
+ 1. `lib/excavate.rb` — the immediate-parent file for `Excavate::*`. Move
46
+ error classes to `lib/excavate/errors.rb` and autoload it. Autoload
47
+ `Archive`, `FileMagic`, `Utils`, `CLI`, `Extractors`. Keep
48
+ `Excavate::VERSION` autoloaded too.
49
+ 2. `lib/excavate/extractors.rb` — the immediate-parent file for
50
+ `Excavate::Extractors::*`. Replace all 11 `require_relative` lines with
51
+ `autoload` declarations.
52
+ 3. `lib/excavate/cli.rb` — drop the `require_relative "../excavate"`. CLI is
53
+ now autoloaded by the parent; the `thor` external require stays.
54
+ 4. `lib/excavate/extractors/ole_extractor.rb` — drop the
55
+ `require_relative "../file_magic"`. `FileMagic` is reachable via the
56
+ autoload chain `Excavate → FileMagic`.
57
+ 5. Create `lib/excavate/errors.rb` hosting the error hierarchy; have
58
+ `excavate.rb` autoload it eagerly so exception classes are always
59
+ defined for rescue clauses.
60
+
61
+ ## Acceptance
62
+
63
+ - `grep -rn "require_relative" lib/` returns zero hits.
64
+ - `bundle exec rspec` is green.
65
+ - Loading the gem remains a single `require "excavate"` (Bundler-friendly).
@@ -0,0 +1,48 @@
1
+ # 02 — Drop redundant external requires
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ `omnizip` itself uses `autoload` for every `Formats::*` constant (see
8
+ `omnizip/formats.rb`). The per-format `require "omnizip/formats/cpio"` (and
9
+ siblings) in our extractors are redundant — they re-trigger the same autoload
10
+ resolution.
11
+
12
+ Additionally the currently-staged (uncommitted) change to
13
+ `cpio_extractor.rb` adds a second external require line:
14
+ `require "omnizip"` followed by `require "omnizip/formats/cpio"`. Only one is
15
+ needed.
16
+
17
+ ## Scope
18
+
19
+ Files with redundant per-format requires:
20
+
21
+ ```
22
+ lib/excavate/extractors/cpio_extractor.rb:3-4 require "omnizip" / require "omnizip/formats/cpio"
23
+ lib/excavate/extractors/ole_extractor.rb:3-4 require "omnizip" / require "omnizip/formats/ole"
24
+ lib/excavate/extractors/rpm_extractor.rb:3-4 require "omnizip" / require "omnizip/formats/rpm"
25
+ lib/excavate/extractors/xar_extractor.rb:3-4 require "omnizip" / require "omnizip/formats/xar"
26
+ lib/excavate/extractors/seven_zip_extractor.rb:3 require "omnizip"
27
+ lib/excavate/extractors/tar_extractor.rb:3 require "omnizip"
28
+ lib/excavate/extractors/xz_extractor.rb:3 require "omnizip"
29
+ lib/excavate/extractors/zip_extractor.rb:3 require "omnizip"
30
+ ```
31
+
32
+ ## Plan
33
+
34
+ Keep one `require "omnizip"` per file that touches an Omnizip constant.
35
+ Delete the per-format `require "omnizip/formats/X"` lines — the autoload in
36
+ `omnizip/formats.rb` resolves them on first reference. The staged
37
+ `require "omnizip"` addition in cpio_extractor.rb becomes the single
38
+ omnizip require for that file (replacing the redundant format require).
39
+
40
+ For extractors that only touch a single Omnizip format constant, the
41
+ `require "omnizip"` line is also technically unneeded if any other loaded
42
+ file already requires omnizip — but keeping it makes each file
43
+ independently loadable, which is good practice. Keep them.
44
+
45
+ ## Acceptance
46
+
47
+ - No `require "omnizip/formats/..."` remains in `lib/`.
48
+ - `bundle exec rspec` is green.
@@ -0,0 +1,150 @@
1
+ # 03 — Open/closed Extractor registry
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ `Excavate::Extractors::Extractor.for_magic_type` currently consults a frozen
8
+ `MAGIC_MAP` keyed by symbol → class-name-string and resolves via
9
+ `const_get`. Adding a new extractor requires editing this hash. That
10
+ violates OCP: the base class is open for *modification*, not just extension.
11
+
12
+ The current spec even hard-codes the expected key list
13
+ (`spec/excavate/extractors/extractor_spec.rb:6`), which means every new
14
+ extractor needs edits in three places: the new file, the parent
15
+ `extractors.rb`, and `MAGIC_MAP`. After this refactor only the new file
16
+ needs to change.
17
+
18
+ ## Plan
19
+
20
+ Replace the hash + const_get with a registry pattern:
21
+
22
+ ```ruby
23
+ class Extractor
24
+ @registry = {}
25
+
26
+ class << self
27
+ private :inherited # keep the hook itself private — registration is the API
28
+
29
+ def inherited(subclass)
30
+ super
31
+ subclass.instance_variable_set(:@handled_types, [])
32
+ end
33
+
34
+ def handles(*types)
35
+ @handled_types.concat(types)
36
+ Extractor.register(self, types)
37
+ end
38
+ end
39
+
40
+ def self.register(klass, types)
41
+ types.each { |t| registry[t] = klass }
42
+ end
43
+
44
+ def self.for_magic_type(type)
45
+ registry[type]
46
+ end
47
+
48
+ def self.registry
49
+ @registry
50
+ end
51
+ end
52
+ ```
53
+
54
+ Wait — the user forbids `instance_variable_set`. Use a different shape:
55
+
56
+ ```ruby
57
+ class Extractor
58
+ @registry = {}
59
+
60
+ class << self
61
+ attr_reader :registry
62
+
63
+ def handles(*types)
64
+ types.each { |t| registry[t] = self }
65
+ end
66
+
67
+ def for_magic_type(type)
68
+ registry[type]
69
+ end
70
+
71
+ def inherited(subclass)
72
+ super
73
+ subclass.instance_variable_set(:@handled_types, []) # FORBIDDEN
74
+ end
75
+ end
76
+ end
77
+ ```
78
+
79
+ Final design — no instance_variable_* and no const_get. Each subclass calls
80
+ `handles :cab` at class-body load time, which writes itself into the
81
+ parent's registry hash. The `inherited` hook is not needed because
82
+ registration is explicit at class definition.
83
+
84
+ ```ruby
85
+ # extractors/extractor.rb
86
+ module Excavate
87
+ module Extractors
88
+ class Extractor
89
+ class << self
90
+ # Public registry API. Each subclass declares the magic types it
91
+ # handles via `handles :type, ...` at class-body load time.
92
+ def handles(*magic_types)
93
+ magic_types.each { |t| registry[t] = self }
94
+ end
95
+
96
+ def for_magic_type(type)
97
+ registry[type]
98
+ end
99
+
100
+ def registry
101
+ @registry ||= {}
102
+ end
103
+ end
104
+
105
+ def initialize(archive)
106
+ @archive = archive
107
+ end
108
+
109
+ def extract(_target)
110
+ raise NotImplementedError, "you must implement #extract"
111
+ end
112
+
113
+ private
114
+
115
+ def extract_inner(data, target) ... end
116
+ def write_raw_output(data, target) ... end
117
+ end
118
+ end
119
+ end
120
+ ```
121
+
122
+ Each subclass:
123
+
124
+ ```ruby
125
+ class CabExtractor < Extractor
126
+ handles :cab
127
+ ...
128
+ end
129
+
130
+ class SevenZipExtractor < Extractor
131
+ handles :seven_zip, :exe
132
+ ...
133
+ end
134
+ ```
135
+
136
+ ## Spec impact
137
+
138
+ - Remove `MAGIC_MAP` frozen spec — the registry hash is no longer public.
139
+ - Replace the per-type expectation loop with a "registered" check using
140
+ `Extractor.for_magic_type(...)`.
141
+ - The "is registered for :type" specs in each extractor_spec.rb still pass
142
+ unchanged because they assert via `for_magic_type`.
143
+
144
+ ## Acceptance
145
+
146
+ - Adding a new format requires only: new file under
147
+ `lib/excavate/extractors/`, new autoload entry in
148
+ `lib/excavate/extractors.rb`, `handles :foo` in the class body.
149
+ - No edits to any other file.
150
+ - All 185 specs pass (after extractor_spec is updated to drop MAGIC_MAP).
@@ -0,0 +1,68 @@
1
+ # 04 — FileMagic::Signature value object
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ `FileMagic::SIGNATURES` is an array of `[offset, magic, type]` tuples.
8
+ Reading call sites like `SIGNATURES.map { |o, m, _| o + m.bytesize }` or
9
+ `SIGNATURES.each do |offset, magic, type|` requires the reader to memorise
10
+ the positional convention. A named value object removes that cognitive load
11
+ and documents intent.
12
+
13
+ ## Plan
14
+
15
+ Introduce `FileMagic::Signature = Struct.new(:offset, :magic, :type)` and
16
+ rewrite SIGNATURES as a list of `Signature.new(...)` literals. Update
17
+ `detect_bytes` and `MAX_READ` to use field names.
18
+
19
+ Sketch:
20
+
21
+ ```ruby
22
+ module Excavate
23
+ class FileMagic
24
+ Signature = Struct.new(:offset, :magic, :type)
25
+
26
+ SIGNATURES = [
27
+ Signature.new(0, "MSCF\x00\x00\x00\x00".b, :cab),
28
+ Signature.new(0, "\xFD7zXZ\x00".b, :xz),
29
+ Signature.new(0, "\x1F\x8B".b, :gzip),
30
+ Signature.new(257, "ustar".b, :tar),
31
+ Signature.new(0, "7z\xBC\xAF\x27\x1C".b, :seven_zip),
32
+ Signature.new(0, "PK\x03\x04".b, :zip),
33
+ Signature.new(0, "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1".b, :ole),
34
+ Signature.new(0, "xar!".b, :xar),
35
+ Signature.new(0, "\xED\xAB\xEE\xDB".b, :rpm),
36
+ Signature.new(0, "070707".b, :cpio),
37
+ Signature.new(0, "070701".b, :cpio),
38
+ Signature.new(0, "070702".b, :cpio),
39
+ Signature.new(0, "MZ".b, :exe),
40
+ ].freeze
41
+
42
+ MAX_READ = SIGNATURES.map { |s| s.offset + s.magic.bytesize }.max
43
+
44
+ def self.detect(path)
45
+ detect_bytes(File.read(path, MAX_READ, mode: "rb"))
46
+ end
47
+
48
+ def self.detect_bytes(data)
49
+ return nil if data.nil? || data.empty?
50
+
51
+ SIGNATURES.each do |signature|
52
+ next if data.bytesize < signature.offset + signature.magic.bytesize
53
+
54
+ return signature.type if data.byteslice(signature.offset, signature.magic.bytesize) == signature.magic
55
+ end
56
+
57
+ nil
58
+ end
59
+ end
60
+ end
61
+ ```
62
+
63
+ `Signature` is internal to FileMagic so no autoload entry needed.
64
+
65
+ ## Acceptance
66
+
67
+ - All file_magic_spec examples pass unchanged.
68
+ - No positional-destructuring of signature tuples remains.
@@ -0,0 +1,57 @@
1
+ # 05 — Clean up `Utils.silence_stream` dead branch
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ Current implementation:
8
+
9
+ ```ruby
10
+ def silence_stream(stream)
11
+ old_stream = stream.dup
12
+ stream.reopen(/mswin|mingw/.match?(RbConfig::CONFIG["host_os"]) ? File::NULL : File::NULL)
13
+ stream.sync = true
14
+ yield
15
+ ensure
16
+ stream.reopen(old_stream)
17
+ end
18
+ ```
19
+
20
+ Both arms of the ternary return `File::NULL`, so the OS-detection branch
21
+ is dead code. RuboCop already flags it as `Lint/DuplicateBranch` and
22
+ `Style/IdenticalConditionalBranches` in `.rubocop_todo.yml`.
23
+
24
+ Also: `RbConfig` is referenced without `require "rbconfig"`. It happens to
25
+ work because Ruby preloads it, but explicit is better.
26
+
27
+ ## Plan
28
+
29
+ Two acceptable resolutions:
30
+
31
+ **Option A** — delete the ternary entirely:
32
+
33
+ ```ruby
34
+ def silence_stream(stream)
35
+ old_stream = stream.dup
36
+ stream.reopen(File::NULL)
37
+ stream.sync = true
38
+ yield
39
+ ensure
40
+ stream.reopen(old_stream)
41
+ end
42
+ ```
43
+
44
+ **Option B** — delete the whole `Utils` module. Its only caller is
45
+ `spec/excavate/cli_spec.rb` which uses it to swallow `$stdout` during CLI
46
+ tests. That could be replaced with RSpec's `output(...).to_stdout` matcher
47
+ (which is already used elsewhere in the same file) — but the silence_stream
48
+ approach is more robust when the test doesn't care about the message body.
49
+
50
+ Choose **Option A** — keep the helper, just remove the dead code.
51
+
52
+ ## Acceptance
53
+
54
+ - `Utils.silence_stream` no longer has identical branches.
55
+ - `Lint/DuplicateBranch` and `Style/IdenticalConditionalBranches` can be
56
+ removed from `.rubocop_todo.yml`.
57
+ - cli_spec continues to silence $stdout.
@@ -0,0 +1,72 @@
1
+ # 06 — Extract `Excavate::Filesystem` helper
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ `Excavate::Archive` currently owns four Windows-safe retry helpers
8
+ (`windows_safe_rm`, `windows_safe_rm_rf`, plus the inline
9
+ `replace_archive_with_contents` rescue logic). These are pure
10
+ filesystem operations with retry-on-`EACCES`/`ENOTEMPTY` semantics and
11
+ have nothing to do with archives. They violate MECE: the Archive class
12
+ is mixing archive orchestration with low-level FS reliability.
13
+
14
+ ## Plan
15
+
16
+ Create `lib/excavate/filesystem.rb`:
17
+
18
+ ```ruby
19
+ module Excavate
20
+ module Filesystem
21
+ module_function
22
+
23
+ RETRYABLE_ERRORS = [Errno::EACCES, Errno::ENOTEMPTY].freeze
24
+ DEFAULT_MAX_RETRIES = 5
25
+ DEFAULT_RETRY_DELAY = 0.2
26
+
27
+ def remove(path, max_retries: DEFAULT_MAX_RETRIES, delay: DEFAULT_RETRY_DELAY)
28
+ with_retry(max_retries: max_retries, delay: delay) { FileUtils.rm(path) }
29
+ end
30
+
31
+ def remove_recursive(path, max_retries: DEFAULT_MAX_RETRIES, delay: DEFAULT_RETRY_DELAY)
32
+ with_retry(max_retries: max_retries, delay: delay) { FileUtils.rm_rf(path) }
33
+ end
34
+
35
+ def with_retry(max_retries: DEFAULT_MAX_RETRIES, delay: DEFAULT_RETRY_DELAY)
36
+ attempts = 0
37
+ begin
38
+ yield
39
+ rescue *RETRYABLE_ERRORS => e
40
+ attempts += 1
41
+ raise e if attempts >= max_retries
42
+
43
+ sleep(delay)
44
+ retry
45
+ end
46
+ end
47
+ end
48
+ end
49
+ ```
50
+
51
+ Update `lib/excavate.rb` to autoload `:Filesystem,
52
+ "excavate/filesystem"`.
53
+
54
+ Update `lib/excavate/archive.rb`:
55
+
56
+ - `windows_safe_rm(path, ...)` → `Filesystem.remove(path)`
57
+ - `windows_safe_rm_rf(path, ...)` → `Filesystem.remove_recursive(path)`
58
+ - The `ensure ... FileUtils.rm_rf(target)` clauses in
59
+ `extract_particular_files` and `extract_by_filter` should also use
60
+ `Filesystem.remove_recursive` for consistency.
61
+ - `replace_archive_with_contents` already rescues Errno::EACCES for a
62
+ *different* reason (Windows file lock during mv); leave it but route
63
+ the `windows_safe_rm(archive)` call through `Filesystem.remove`.
64
+
65
+ ## Acceptance
66
+
67
+ - Archive no longer has private `windows_safe_*` methods.
68
+ - New file `lib/excavate/filesystem.rb` exists with autoload.
69
+ - All archive specs still pass.
70
+ - New spec `spec/excavate/filesystem_spec.rb` covers both methods'
71
+ happy path and retry behaviour (use a fake error class with a stub
72
+ that fails N-1 times then succeeds).
@@ -0,0 +1,62 @@
1
+ # 07 — Extract `Excavate::Targets` helper
2
+
3
+ Status: **pending**
4
+
5
+ ## Why
6
+
7
+ `Excavate::Archive` owns three target-path safety helpers
8
+ (`ensure_not_exist`, `ensure_empty`, `default_target`). They are pure
9
+ path policy with no archive context — they belong in a focused value
10
+ object so they can be tested in isolation and reused.
11
+
12
+ ## Plan
13
+
14
+ Create `lib/excavate/targets.rb`:
15
+
16
+ ```ruby
17
+ module Excavate
18
+ module Targets
19
+ module_function
20
+
21
+ def ensure_absent(path)
22
+ return unless File.exist?(path)
23
+
24
+ kind = File.directory?(path) ? "directory" : "file"
25
+ raise TargetExistsError,
26
+ "Target #{kind} `#{File.basename(path)}` already exists."
27
+ end
28
+
29
+ def ensure_empty(path)
30
+ return if Dir.empty?(path)
31
+
32
+ raise TargetNotEmptyError,
33
+ "Target directory `#{File.basename(path)}` is not empty."
34
+ end
35
+
36
+ def default_for(source)
37
+ target = File.expand_path(File.basename(source, ".*"))
38
+ ensure_absent(target)
39
+ FileUtils.mkdir(target)
40
+ target
41
+ end
42
+ end
43
+ end
44
+ ```
45
+
46
+ Update `lib/excavate.rb` to autoload `:Targets, "excavate/targets"`.
47
+
48
+ Update `lib/excavate/archive.rb`:
49
+
50
+ - `ensure_not_exist(path)` → `Targets.ensure_absent(path)`
51
+ - `ensure_empty(path)` → `Targets.ensure_empty(path)`
52
+ - `default_target(source)` → `Targets.default_for(source)`
53
+
54
+ Remove the now-empty private helpers.
55
+
56
+ ## Acceptance
57
+
58
+ - Archive no longer has private target-safety methods.
59
+ - New file `lib/excavate/targets.rb` with autoload.
60
+ - New spec `spec/excavate/targets_spec.rb` covers all three methods
61
+ including the error paths.
62
+ - Existing archive + cli specs still pass.