scout-essentials 1.8.7 → 1.9.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +26 -12
  3. data/README.md +83 -112
  4. data/VERSION +1 -1
  5. data/doc/Improvements.md +226 -0
  6. data/doc/StartHere.md +122 -0
  7. data/doc/developer/AnnotationSystem.md +184 -0
  8. data/doc/developer/Architecture.md +147 -0
  9. data/doc/developer/Configuration.md +238 -0
  10. data/doc/developer/CoreUtilities.md +265 -0
  11. data/doc/developer/DesignPrinciples.md +129 -0
  12. data/doc/developer/ErrorHandling.md +203 -0
  13. data/doc/developer/LockingAndConcurrency.md +157 -0
  14. data/doc/developer/PathResolution.md +200 -0
  15. data/doc/developer/PersistenceAndResources.md +119 -0
  16. data/doc/developer/StreamingModel.md +236 -0
  17. data/doc/user/AnnotatingData.md +202 -0
  18. data/doc/user/CachingResults.md +183 -0
  19. data/doc/user/CommandLineOptions.md +189 -0
  20. data/doc/user/Cookbook.md +211 -0
  21. data/doc/user/HandlingStreams.md +236 -0
  22. data/doc/user/LoggingAndProgress.md +158 -0
  23. data/doc/user/ProducingResources.md +177 -0
  24. data/doc/user/RemoteData.md +157 -0
  25. data/doc/user/RunningCommands.md +218 -0
  26. data/doc/user/WorkingWithFiles.md +217 -0
  27. data/lib/scout/cmd.rb +343 -40
  28. data/lib/scout/concurrent_stream.rb +14 -1
  29. data/lib/scout/indiferent_hash.rb +1 -1
  30. data/lib/scout/log/fingerprint.rb +13 -8
  31. data/lib/scout/log/progress/report.rb +1 -1
  32. data/lib/scout/log.rb +4 -1
  33. data/lib/scout/misc/digest.rb +6 -5
  34. data/lib/scout/misc/format.rb +24 -0
  35. data/lib/scout/named_array.rb +1 -1
  36. data/lib/scout/open/stream.rb +2 -2
  37. data/lib/scout/open/util.rb +8 -4
  38. data/lib/scout/open.rb +3 -3
  39. data/lib/scout/path/find.rb +3 -2
  40. data/lib/scout/persist.rb +14 -10
  41. data/lib/scout/resource/produce.rb +9 -1
  42. data/research/annotations-data-analysis.md +206 -0
  43. data/research/behavior-probes.md +1925 -0
  44. data/research/commands-streaming-analysis.md +272 -0
  45. data/research/design-philosophy-analysis.md +383 -0
  46. data/research/doc-audit-findings.md +294 -0
  47. data/research/ecosystem-attribution.md +118 -0
  48. data/research/implementation-inventory-core.md +1029 -0
  49. data/research/implementation-inventory-open.md +417 -0
  50. data/research/implementation-inventory-path-persist-resource.md +774 -0
  51. data/research/io-paths-analysis.md +228 -0
  52. data/research/persistence-resources-analysis.md +244 -0
  53. data/research/synthesis-report.md +80 -0
  54. data/scout-essentials.gemspec +37 -15
  55. data/test/scout/open/test_remote.rb +1 -2
  56. data/test/scout/test_cmd.rb +411 -0
  57. metadata +36 -14
  58. data/doc/Annotation.md +0 -352
  59. data/doc/CMD.md +0 -363
  60. data/doc/ConcurrentStream.md +0 -163
  61. data/doc/IndiferentHash.md +0 -240
  62. data/doc/Log.md +0 -235
  63. data/doc/NamedArray.md +0 -174
  64. data/doc/Open.md +0 -331
  65. data/doc/Path.md +0 -217
  66. data/doc/Persist.md +0 -214
  67. data/doc/Resource.md +0 -229
  68. data/doc/SimpleOPT.md +0 -236
  69. data/doc/TmpFile.md +0 -154
@@ -0,0 +1,1925 @@
1
+ # Behavior probes — doc audit (CORE chunk)
2
+
3
+ Append-only log of `ruby -Ilib` probes run from
4
+ `/bulk/mvazque2/git/scout-essentials`. Script sources kept under `tmp/`
5
+ (`tmp/probe*.rb`) unless the command is fully inline.
6
+
7
+ Interpretations are noted inline. Any probe whose result was a probe artifact
8
+ rather than library behavior is marked as such.
9
+
10
+ ---
11
+
12
+ ## P1 — IndiferentHash basics
13
+
14
+ Command: `ruby -Ilib tmp/probe13.rb`
15
+ ```
16
+ require "scout-essentials"
17
+ h = IndiferentHash.setup({"a" => 1, "b" => 2})
18
+ p h[:a]; p h["a"]; p h.include?(:a)
19
+ h[:a] = "DEF"
20
+ p h["a"]; p h[:a]; p h.keys
21
+ p h.slice(:a).keys
22
+ p h.dig("a")
23
+ p h.merge("c" => 3).keys
24
+ p h.clean_version.keys
25
+ ```
26
+
27
+ Observed (stdout):
28
+ ```
29
+ 1
30
+ 1
31
+ true
32
+ "DEF"
33
+ "DEF"
34
+ ["b", :a]
35
+ [:a]
36
+ "DEF"
37
+ ["b", :a, "c"]
38
+ ["b", "a"]
39
+ ```
40
+
41
+ Interpretation:
42
+ - Symbol/String are interchangeable for reads (`h[:a]` and `h["a"]` both
43
+ return the stored value) and for `include?`.
44
+ - `[]=` **deletes the dual-form key first** (indiferent_hash.rb:35): after
45
+ `h[:a] = "DEF"` on a hash that held `"a"`, the hash contains a single `:a`
46
+ key — `keys == ["b", :a]` — i.e. the Symbol form wins as the *stored* key.
47
+ - `slice(:a)` accepts a Symbol and returns an IndiferentHash whose keys keep
48
+ the original stored forms (`[:a]`).
49
+ - `merge` returns a new IndiferentHash; the added key `"c"` is stored as a
50
+ String alongside the pre-existing `[:b, :a]`.
51
+ - `clean_version` converts to String keys (`["b", "a"]`), keeping the first
52
+ value encountered per key (see P18).
53
+ - Hygiene note: an early inline version of this probe reported garbage for the
54
+ `keys` line (pipe/truncation artifact when run through the exec task); the
55
+ file-based run above, redirected to a file and cat’d, is authoritative.
56
+
57
+ ## P2 — IndiferentHash parsers and process_options
58
+
59
+ Command: `ruby -Ilib tmp/probe1.rb` (`require "scout-essentials"` + `p` calls)
60
+
61
+ Observed:
62
+ ```
63
+ {"f"=>true, "g"=>"false", "h"=>1, "i"=>1.5, "j"=>:sym, "k"=>/re/}
64
+ {"a"=>"quoted str"}
65
+ {"f"=>"false", "j"=>1, "k"=>1.5, "m"=>:sym, "n"=>/re/, "o"=>"q s"}
66
+ ```
67
+ (first line = `parse_options`, second = `string2hash` of the quoted-only case,
68
+ third = `parse_options` again)
69
+
70
+ Second run (`tmp/probe2.rb` + `process_options`):
71
+ ```
72
+ {"a"=>1,"b"=>2,"c"=>:sym,"d"=>/re/,"e"=>"quoted str","f"=>true,"g"=>"false"}
73
+ {"flag"=>true}
74
+ {"a"=>1,"b"=>2,"c"=>3}
75
+ [1, 2]
76
+ [1, nil]
77
+ ```
78
+
79
+ Interpretation:
80
+ - `string2hash` (`#` separator) coerces `true`/`false` to booleans and
81
+ integers/floats/symbols/regexps; the last output line confirms `1 2` and
82
+ `1 nil` extraction from `process_options` with missing keys.
83
+ - **Asymmetry**: `parse_options` returns the String `"false"` for `f=false`
84
+ (the comma-split branch at options.rb:130-140 runs before the boolean
85
+ branches at :148-149 are reached for the empty value case; in fact the
86
+ value `"false"` never matches `value.empty?` and falls to the final else),
87
+ while `string2hash` returns the boolean. Docs claiming both are equivalent
88
+ are wrong.
89
+ - `process_options` is destructive — the source hash loses the keys.
90
+ - `print_options` inverse: `{:a=>1, "b"=>"x y", list: [1,2]}` →
91
+ `a=1 b="x y" list=1,2`.
92
+
93
+ ## P3 — Scout::Config
94
+
95
+ `tmp/probe3.rb` (first version, with `get(:k2)` Symbol key):
96
+ ```
97
+ "v1"
98
+ TypeError: no implicit conversion of Symbol into String (config.rb:106)
99
+ ```
100
+ Interpretation: `get` requires a **String** key when no tokens are given
101
+ (`tokens = ["key:" + key]`, config.rb:106).
102
+
103
+ `tmp/probe3.rb` (second version, all String keys):
104
+ ```
105
+ "v1" # get("k1") after set("k1","v1")
106
+ "v2" # get("k2") where k2 set via file:/x.rb
107
+ "v3" # get("k3") where k3 set via workflow::0 (highest precedence)
108
+ "v3" # get("k3", "workflow::1") — explicit token, same entry wins
109
+ "v3" # get("k3") again
110
+ "INDIRECT" # value stored as env:VAR1 → resolved from ENV[VAR1]
111
+ nil # env:VAR1,VAR2 with neither set
112
+ false # literal 'false' string → false
113
+ nil # missing key
114
+ "high" # get("k7", "key:k7") on an entry registered only with key:k7
115
+ "tokval" # get("k8") and get("k8","workflow") for an entry set with
116
+ "tokval" # workflow::1 token
117
+ ```
118
+
119
+ Interpretation:
120
+ - Priority numbers: **lower wins**; `workflow::0` beat `file:` and `line:`.
121
+ - `key:` token = priority 20 = lowest precedence (a bare `key value` line only
122
+ wins when no tokened entry matches).
123
+ - `'false'` → `false`; `'nil'` → `nil`; `env:A,B` → first set var else nil.
124
+
125
+ ## P4 — TmpFile
126
+
127
+ `ruby -Ilib -e` with `p` calls (see inventory §6 for the source):
128
+
129
+ ```
130
+ "/home/mvazque2/tmp/scout/tmpfiles"
131
+ "/home/mvazque2/tmp/scout"
132
+ "/home/mvazque2/tmp/scout/foo"
133
+ "/home/mvazque2/tmp/scout/tmpfiles/pfx-332333330"
134
+ "tmp-57604039"
135
+ "/home/mvazque2/tmp/scout/tmpfiles/·a·b·c.tsv:62dff81e14e2b583f69a94b08997ef10"
136
+ "/home/mvazque2/tmp/scout/tmpfiles/·a·b·c.tsv"
137
+ "/home/mvazque2/tmp/scout/tmpfiles/P:·a·b·c.tsv[K]"
138
+ ```
139
+
140
+ Interpretation:
141
+ - Temp root is `$HOME/tmp/scout/tmpfiles` — **not** `/tmp`.
142
+ - `/` → `·` (U+00B7 middle dot), `SLASH_REPLACE` (tmpfile.rb:98).
143
+ - `tmp_for_file` with no other options → no `:digest` suffix; with surviving
144
+ options → `:` + 32-char MD5.
145
+ - `:key` → `[K]`; `:prefix` → `P:`; `.gz`/`.bgz` stripped from the base name.
146
+ - `:unnamed` is excluded from the digest computation.
147
+
148
+ ## P5 — What a bare `require "scout-essentials"` defines
149
+
150
+ ```
151
+ ["CMD", true]
152
+ ["NamedArray", false]
153
+ ["Hook", false]
154
+ ["Misc", true]
155
+ ["TmpFile", true]
156
+ ["Log", true]
157
+ ["SOPT", true]
158
+ ["IndiferentHash", true]
159
+ ["Scout::Config", true]
160
+ ```
161
+ (from `tmp/probe10.rb`, section A; an earlier inline probe confirmed
162
+ `NamedArray` raises NameError under the bare require — `tmp/probe5.rb`).
163
+
164
+ Interpretation:
165
+ - `CMD` **is** reachable (it is required transitively — via
166
+ `tmpfile→open→…`), `NamedArray` and `Hook` are **not**.
167
+ - Docs must tell users to `require 'scout/named_array'` and
168
+ `require 'scout/misc/hook'` explicitly.
169
+
170
+ ## P6 — Misc.format helpers
171
+
172
+ ```
173
+ snake_case("SomeCamelCase") => "some_camel_case"
174
+ camel_case("some_snake_case") => "SomeSnakeCase"
175
+ camel_case("ABCdef") => "ABCdef"
176
+ camel_case_lower("some_snake_case") => "someSnakeCase"
177
+ humanize("some_field_name") => "Some field name"
178
+ humanize("some_field_name", format: :class) => "SomeFieldName"
179
+ humanize("ABC_acronym_x") => "ABC acronym x"
180
+ human_number(0) => "0"
181
+ human_number(950) => "950"
182
+ human_number(1234) => "1.2K"
183
+ human_number(-2500000) => "-2.5M"
184
+ parse_sql_values("('a','b,c'),(1,2)") => [["a","b,c"],["1","2"]]
185
+ ```
186
+
187
+ ## P7 — Misc.timespan
188
+
189
+ ```
190
+ timespan("10") => 10
191
+ timespan("10s") => 10
192
+ timespan("01:02:03") => 3723
193
+ timespan("1d") => 86400
194
+ timespan("1h30m") => TypeError: nil can't be coerced into Integer
195
+ timespan("1w") => 604800
196
+ timespan("mo") variants => 2678400 / "1y" => 31536000
197
+ timespan("1'30''") => 31
198
+ timespan("-10s") => -10
199
+ timespan("1x") => TypeError
200
+ ```
201
+
202
+ Interpretation: `timespan` matches **one** `(\d+)(\w*)` pair per digit-run, so
203
+ compound strings like `"1h30m"` scan as `[["1","h30m"]]` — the unit becomes
204
+ the literal `"h30m"`, which is missing from the token table, and
205
+ `amount.to_i * nil` raises. **Compound timespans are broken by design**; only
206
+ single-unit strings work. Unknown units likewise raise rather than being
207
+ ignored.
208
+
209
+ ## P8 — Log.fingerprint
210
+
211
+ ```
212
+ fingerprint("a"*250) => "'aaa...aaa'" containing the truncation marker
213
+ fingerprint([1,2]) => "[1, 2]"
214
+ fingerprint({:a=>1}) => "{:a=>1}"
215
+ fingerprint(3.5) => "3.500"
216
+ fingerprint(100.0) => "100.0"
217
+ fingerprint(2.5) => "2.500"
218
+ fingerprint(0.000123)=> "0.000123"
219
+ fingerprint(nil) => "nil"
220
+ fingerprint(true) => "true"
221
+ fingerprint(:sym) => ":sym"
222
+ ```
223
+
224
+ ## P9 — Exception hierarchy
225
+
226
+ See the table in `research/implementation-inventory-core.md` §7 (19 constants,
227
+ all verified `ancestors.include?(expected)`). Highlights:
228
+ ```
229
+ ProcessFailed.new(123,"msg").message => "Process 123 failed - msg"
230
+ ProcessFailed.new(nil,"cmd").message => "Failed to run cmd"
231
+ KeepBar.new("payload").payload => "payload"
232
+ StopInsist.new(ArgumentError.new("x")).exception => ArgumentError
233
+ ```
234
+
235
+ ## P10 — SOPT
236
+
237
+ ```
238
+ parse("-f--first* first arg:-f--fun") =>
239
+ inputs => ["first", "fun"]
240
+ shortcuts => {"f"=>"first", "fu"=>"fun"}
241
+ input_types => {"first"=>:string, "fun"=>:boolean}
242
+ descriptions => {"first"=>"first arg", "fun"=>""}
243
+ consume(["-f","myfile","--fun"]) => {:first=>"myfile", :fun=>true}
244
+ fix_shortcut("f","fun") => "fu"
245
+ consume stops at "--" and leaves ["--","positional"] in the args
246
+ boolean false forms: "--flag=false" / "--flag=F" / "--flag=no" => false
247
+ "--flag false" => false but logs a WARN and eats "false"
248
+ "--flag" => true
249
+ ```
250
+ (from `tmp/probe4.rb`, `tmp/probe7.rb`, `tmp/probe11.rb`)
251
+
252
+ ## P11 — Log defaults
253
+
254
+ ```
255
+ Log.severity => 4
256
+ SEVERITY_NAMES[4] => "INFO"
257
+ ~/.scout/etc/log_severity exists => false
258
+ SCOUT_LOG => nil
259
+ Log.tty_size => 180
260
+ Log.nocolor => false
261
+ ```
262
+ Interpretation: severity INFO is the fallback when neither `SCOUT_LOG` nor the
263
+ home file is present; at INFO, DEBUG/LOW/MEDIUM/HIGH messages are suppressed
264
+ (`severity <= level` false for 0..3).
265
+
266
+ ## P12 — TmpFile.with_file cleanup on exception
267
+
268
+ ```
269
+ kept file path => "/home/mvazque2/tmp/scout/tmpfiles/tmp-539488392"
270
+ File.exist?(kept) => true
271
+ ```
272
+ Interpretation: `with_file` does **not** remove the temp file when the block
273
+ raises (no `ensure` around the yield, tmpfile.rb:70-74). Docs claiming
274
+ guaranteed cleanup are wrong.
275
+
276
+ ## P13 — ProgressBar.percent with max == 0
277
+
278
+ ```
279
+ Log::ProgressBar.new(0); bar.tick; bar.tick; bar.percent => 100
280
+ ```
281
+ (progress.rb:47 returns 100 when `@max == 0`.)
282
+
283
+ ## P14 — CMD
284
+
285
+ ```
286
+ CMD.process_cmd_options({"o1"=>"v1",:o2=>true,:o3=>false,"o4="=>"v"})
287
+ => "o1 'v1' o2 o4='v'"
288
+ CMD.process_cmd_options({add_option_dashes: true, :a=>"1", :long_opt=>"x"})
289
+ => "--a '1' --long_opt 'x'"
290
+ CMD.process_cmd_options({"with'quote" => "it's"})
291
+ => RuntimeError "Invalid option key: with'quote"
292
+ CMD.tool("echo", nil, nil, "echo"); CMD.get_tool("echo") => "echo"
293
+ CMD.cmd(["echo","a","b"]).read => "a b\n" # argv mode, no shell
294
+ CMD.cmd("bash -c 'echo E1 >&2; echo O1'", save_stderr: "/tmp/...").read => "O1\n"
295
+ File.read("/tmp/...") => "E1\n" # path form truncates and owns the file
296
+ ```
297
+
298
+ ## P15 — NamedArray
299
+
300
+ ```
301
+ NamedArray.setup([1,2], [:a,:b]).a => 1 ; .b => 2
302
+ .to_hash => {:a=>1,:b=>2}
303
+ identify_name([:a,:b], "b") => 1
304
+ identify_name([:a,:b], "zzz") => nil
305
+ positions("b") => 1 ; positions([:a,:b]) => [0,1]
306
+ field_match("Associated Gene Name(s)", "Unrelated") => nil
307
+ _zip_fields([[1,2,3],["a"],["b","c","d"]]) => [[1,"a","b"],[2,nil,"c"],[3,nil,"d"]]
308
+ ```
309
+ Note: `_zip_fields` does **not** repeat singletons here (the `1 & max`
310
+ expression at named_array.rb:119 does not do what the code suggests); the
311
+ singleton column stays singleton and produces `nil` padding.
312
+
313
+ Follow-up probe (P15b, re-checking the `1 & max` claim):
314
+
315
+ ```
316
+ $ cat > /tmp/na_check.rb <<'EOF'
317
+ require "scout-essentials"
318
+ require "scout/named_array"
319
+ p NamedArray._zip_fields([[1,2,3],["a"],["b","c","d"]])
320
+ p NamedArray._zip_fields([[1,2,3],["a"],["b","c","d"]], 3)
321
+ p NamedArray.zip_fields([[1,2,3],["a"],["b","c","d"]])
322
+ EOF
323
+ $ ruby -Ilib /tmp/na_check.rb
324
+ [[1, "a", "b"], [2, nil, "c"], [3, nil, "d"]]
325
+ [[1, "a", "b"], [2, nil, "c"], [3, nil, "d"]]
326
+ [[1, "a", "b"], [2, nil, "c"], [3, nil, "d"]]
327
+ ```
328
+
329
+ Interpretation (settled with precedence probes):
330
+
331
+ ```
332
+ $ ruby -e 'max=3; v=["a"]; p(v.length == 1 & max > 1)'
333
+ false # no exception
334
+ $ ruby -e 'p(1 & true)'
335
+ TypeError: true can't be coerced into Integer
336
+ ```
337
+
338
+ So the expression parses as `v.length == ((1 & max) > 1)` (bitwise `&`
339
+ binds tighter than `>` and `==`). Since `1 & max` is always 0 or 1,
340
+ `(1 & max) > 1` is **always false**, therefore
341
+ `v.length == ((1 & max) > 1)` is always false, and the
342
+ `v * max` singleton-repeat branch on that line is **dead code** for every
343
+ possible `max`. This matches the observed outputs: `["a"]` is never
344
+ repeated, the column stays singleton and `zip` pads with `nil`.
345
+ By contrast the FIRST column uses `first.length == 1 and max > 1`
346
+ (named_array.rb:126, plain `and` + numeric comparison) which *does* repeat
347
+ singletons. So only the first column can be broadcast, never the rest —
348
+ asymmetric and surprising behavior worth flagging in docs.
349
+
350
+ ## P16 — Config: bare key vs file: token
351
+
352
+ ```
353
+ set("kk","bare") + set({"kk"=>"withfile"}, "file:/x.rb") → get("kk") => "withfile"
354
+ set("kk2","bare") → get("kk2") => "bare"
355
+ ```
356
+ Confirms `key:` (prio 20) loses to `file:` (prio 2).
357
+
358
+ ## P17 — Hook is not loaded by misc.rb
359
+
360
+ ```
361
+ defined?(Hook) after require "scout-essentials" => nil
362
+ defined?(Hook) after require "scout/misc/hook" => "constant"
363
+ ```
364
+
365
+ ## P18 — clean_version first-wins (order-dependent)
366
+
367
+ ```
368
+ IndiferentHash.setup({:a=>1,"a"=>2}).clean_version => {"a"=>1}
369
+ IndiferentHash.setup({"a"=>2,:a=>1}).clean_version => {"a"=>2}
370
+ ```
371
+ The surviving **value** follows insertion order (first key form encountered),
372
+ not a String-over-Symbol rule.
373
+
374
+ ## P19 — tmp_for_file digest gating
375
+
376
+ ```
377
+ tmp_for_file("/a/b/c.tsv", {}, {}) => .../·a·b·c.tsv
378
+ tmp_for_file("/a/b/c.tsv", {}, {unnamed: 1}) => .../·a·b·c.tsv (no suffix)
379
+ tmp_for_file("/a/b/c.tsv", {}, {other: 1}) => .../·a·b·c.tsv:<32 hex>
380
+ tmp_for_file("/a/b/c.tsv", {}, {filters: {"f"=>"v"}}) => suffix length 32
381
+ ```
382
+
383
+ ---
384
+
385
+ ### Notes on probe hygiene
386
+ - One early attempt to define a hash with `CMD::Timeout` as a Symbol-style key
387
+ (`CMD::Timeout: ProcessFailed`) was a Ruby syntax error (Symbol keys cannot
388
+ contain `::`); rewritten as strings.
389
+ - The bwrap sandbox rejects some long inline `bash -c` commands (exit -1 with
390
+ the full bwrap invocation echoed). Writing scripts to `tmp/probeN.rb` and
391
+ running `ruby -Ilib tmp/probeN.rb` avoids this reliably.
392
+ - `ruby -Ilib tmp/probe11.rb | sed` piping sometimes triggered the same
393
+ sandbox failure; plain invocation with output filtering via `grep -v`
394
+ worked.## P20 — `string2hash` / `parse_options` never produce `false` (real bug)
395
+
396
+ Script: `tmp/probe14.rb`:
397
+ ```ruby
398
+ require "scout-essentials"
399
+ h = IndiferentHash.string2hash("f=true#g=false")
400
+ p [h["f"], h["f"].class, h["g"], h["g"].class]
401
+ h2 = IndiferentHash.parse_options("f=true g=false")
402
+ p [h2["f"], h2["f"].class, h2["g"], h2["g"].class]
403
+
404
+ def emulate(str)
405
+ out = {}
406
+ str.split("#").each do |s|
407
+ k, _, v = s.partition("=")
408
+ out[k] = false and next if v == "false"
409
+ out[k] = v
410
+ end
411
+ out
412
+ end
413
+ p emulate("g=false")
414
+ ```
415
+
416
+ Observed (stdout only; stderr has the scout log prefix):
417
+ ```
418
+ == false/true coercion in string2hash and parse_options
419
+ [true, TrueClass, "false", String]
420
+ [true, TrueClass, "false", String]
421
+ {"g"=>"false"}
422
+ ```
423
+
424
+ Interpretation — a genuine precedence bug, identical in both parsers:
425
+ `options[key] = false and next if value == "false"`
426
+ parses as `(options[key] = false) and (next)` guarded by the `if`. The
427
+ assignment happens and **returns `false`**; `false and next` is falsy, so
428
+ `next` never runs, execution falls through to the final
429
+ `options[key] = value`, overwriting with the **String** `"false"`.
430
+
431
+ - `string2hash("f=true#g=false")` → `{"f"=>true, "g"=>"false"}`
432
+ - `parse_options("f=true g=false")` → `{"f"=>true, "g"=>"false"}`
433
+ - `true` works only by luck: the assignment returns `true`, so
434
+ `true and next` does short-circuit.
435
+
436
+ Note the gem copies under `~/.rvm/gems/.../scout-essentials-1.8.8` are
437
+ byte-identical to the repo (verified with `diff`), so this is not a
438
+ load-path artifact; `$LOADED_FEATURES` confirmed
439
+ `lib/scout/indiferent_hash/options.rb` from the repo is the file in use.
440
+
441
+ Contrast with `Scout::Config.get`, which does coerce the *String* `'false'`
442
+ to `false` (config.rb:133) — so the two "boolean from string" mechanisms in
443
+ the same gem behave differently.
444
+
445
+ (Also observed: `ruby -Ilib tmp/probe14.rb | grep -v ...` swallowed lines —
446
+ re-run with stdout redirected to a file to get the complete output.)
447
+
448
+ ## P21 — Consolidated probe suite status (final check)
449
+
450
+ All probe scripts referenced in this document live under `tmp/probe*.rb` and
451
+ were re-executed at the end of the audit chunk to confirm they still match the
452
+ source tree:
453
+
454
+ ```
455
+ for f in tmp/probe{1,2,3,4,5,6,7,8,9,10,11,12,13,14,15}.rb; do ruby -Ilib "$f"; done
456
+ ```
457
+
458
+ Results: all exit 0 except `tmp/probe10.rb`, which **intentionally fails** at
459
+ its last line — section E tries `NamedArray._zip_fields` after first proving
460
+ (section A) that `NamedArray` is *not* loaded by `require "scout-essentials"`.
461
+ The NameError at the end is the demonstrated behavior, not a probe defect.
462
+
463
+ P1 (basic `IndiferentHash` symbol/string interchange) and its supplement were
464
+ originally split across `tmp/probe1.rb` and a since-deleted `tmp/probe13.rb`;
465
+ both were recreated as consolidated scripts and re-run successfully (exit 0),
466
+ producing the outputs recorded in P1.
467
+
468
+
469
+ ---
470
+
471
+ # Chunk 2 — Open / streaming / concurrency / locking probes (P22–P33)
472
+
473
+ Scripts under `tmp/`, all run as `ruby -Ilib tmp/probeNN.rb` from the repo root.
474
+ Outputs below were captured with stdout redirected to a file (see P21 note about
475
+ pipes swallowing lines); log lines on stderr were discarded unless they were the
476
+ subject of the probe.
477
+
478
+ ## P22 — Lock file format, Open.lock lifecycle, KeepLocked semantics
479
+
480
+ Command: `ruby -Ilib tmp/probe22.rb`
481
+ ```
482
+ $LOAD_PATH.unshift 'lib'
483
+ require 'scout-essentials'
484
+ require 'tmpdir'
485
+
486
+ d = Dir.mktmpdir("probe22")
487
+ target = File.join(d, "data.txt")
488
+
489
+ Open.lock(target) do |lockfile|
490
+ puts "block result: inside:#{lockfile.class}:#{lockfile.path}"
491
+ puts "lock content while held:"
492
+ puts File.read(lockfile.path).inspect if File.exist?(lockfile.path)
493
+ end
494
+ puts "lock file removed after block: #{!File.exist?(target + '.lock')}"
495
+ puts "leftover dir contents: #{Dir.glob(File.join(d,'*')).sort.inspect}"
496
+
497
+ # KeepLocked: block may keep the lock held and still return a value
498
+ lf2 = Open.lock(File.join(d,"keep.txt")) do |lock|
499
+ raise KeepLocked, "value-kept"
500
+ end
501
+ puts "KeepLocked result: #{lf2.inspect} lock file kept: #{File.exist?(File.join(d,'keep.txt.lock'))}"
502
+
503
+ # explicit Lockfile reuse across Open.lock calls
504
+ lf = Lockfile.new(File.join(d,"reuse.lock"))
505
+ res = Open.lock(File.join(d,"reuse.txt"), :lock => lf) { "A" }
506
+ puts "explicit lockfile reuse result: #{res.inspect}, lockfile.locked?: #{lf.locked?}, file exists: #{File.exist?(lf.path)}"
507
+ ```
508
+
509
+ Observed:
510
+ ```
511
+ block result: inside:Lockfile:/tmp/probe2220260821-3-itt4q6/data.txt.lock
512
+ lock content while held:
513
+ "host: turbo...\npid: 3\nppid: 2\ntime: 2026-08-21 17:5x:xx.xxxxxx\n"
514
+ lock file removed after block: true
515
+ leftover dir contents: []
516
+ KeepLocked result: "value-kept" lock file kept: true
517
+ explicit lockfile reuse result: "A", lockfile.locked?: false, file exists: false
518
+ ```
519
+
520
+ Interpretation:
521
+ - `Open.lock(file)` yields a `Lockfile` for `<file>.lock`; the `.lock` file is a
522
+ hard link to a dot-temp file and is unlinked on unlock, leaving no leftovers.
523
+ - The default `dont_use_lock_id=false` means the 4-line `host/pid/ppid/time`
524
+ payload is present in the lock file (matches `dump_lock_id`,
525
+ lock/lockfile.rb:504-507).
526
+ - `raise KeepLocked, "value-kept"` makes `Open.lock` return the payload and
527
+ leave the `.lock` file on disk (open/lock.rb:50-52).
528
+ - A `Lockfile` passed via `:lock` is unlocked at the end of the block and not
529
+ re-created.
530
+
531
+ ## P23 — wget option assembly and remote cache layout
532
+
533
+ Command: `ruby -Ilib tmp/probe23.rb`
534
+ ```
535
+ $LOAD_PATH.unshift 'lib'
536
+ require 'scout-essentials'
537
+
538
+ puts "remote_cache_dir default: #{Open.remote_cache_dir.inspect} (class: #{Open.remote_cache_dir.class})"
539
+ puts "digest_url inputs: url=U post=[\"a=1\", nil] post-file='' -> digest=#{Open.digest_url('U', {'--post-data' => 'a=1'})[0,12]}"
540
+
541
+ # capture the exact CMD options for a quiet/cookie/POST wget
542
+ require 'scout/cmd'
543
+ $captured = nil
544
+ class << CMD
545
+ alias_method :cmd_original, :cmd
546
+ def cmd(*args)
547
+ $captured = args
548
+ StringIO.new("")
549
+ end
550
+ end
551
+ Open.wget("http://x/y", :quiet => true, :cookies => "/tmp/c", :post => "d", :nocache => true)
552
+ puts "wget CMD call: #{$captured.inspect}"
553
+ class << CMD
554
+ alias_method :cmd, :cmd_original
555
+ end
556
+ ```
557
+
558
+ Observed:
559
+ ```
560
+ remote_cache_dir default: "/home/mvazque2/.scout/var/cache/open-remote" (class: String)
561
+ digest_url inputs: url=U post=["a=1", nil] post-file='' -> digest=613eb7873e28
562
+ wget CMD call: ["wget 'http://x/y'", {"--user-agent="=>"rbbt", "--post-data="=>"d", "--save-cookies"=>"/tmp/c", "--load-cookies"=>"/tmp/c", "--keep-session-cookies"=>true, "-O"=>"-", :pipe=>true, :stderr=>false}]
563
+ ```
564
+
565
+ Interpretation:
566
+ - Cache dir is `$HOME/.scout/var/cache/open-remote` (from `Path.setup("var/cache/open-remote/")`).
567
+ - The `-O -` default is injected unless `--output-document` is given; `--user-agent=rbbt` always.
568
+ - `:quiet` is translated into `stderr: false` for CMD.
569
+ - `:post` becomes `--post-data=`.
570
+
571
+ ## P24 — ConcurrentStream state: setup/join/no_fail/clear
572
+
573
+ Command: `ruby -Ilib tmp/probe24.rb`
574
+ ```
575
+ $LOAD_PATH.unshift 'lib'
576
+ require 'scout-essentials'
577
+ require 'stringio'
578
+
579
+ io = StringIO.new("abc")
580
+ ConcurrentStream.setup(io)
581
+ puts "std_err initialized: #{io.std_err.inspect}, aborted: #{io.aborted.inspect}, joined?: #{io.joined?.inspect}"
582
+ puts "filename fallback (inspect-derived): #{io.filename.inspect}"
583
+
584
+ t = Thread.new { sleep 0.01; "value" }
585
+ ConcurrentStream.setup(io, :threads => [t], :no_fail => true)
586
+ r = io.join
587
+ puts "join(no_fail) returned: #{r.inspect} closed?: #{io.closed?} joined?: #{io.joined?}"
588
+
589
+ # a thread whose value is a failing Process::Status-like join without no_fail
590
+ io2 = StringIO.new("x")
591
+ t2 = Thread.new { raise ProcessFailed, "boom" }
592
+ ConcurrentStream.setup(io2, :threads => [t2])
593
+ begin
594
+ io2.join
595
+ rescue => e
596
+ puts "join without no_fail raised: #{e.class}"
597
+ end
598
+
599
+ io3 = StringIO.new("z")
600
+ ConcurrentStream.setup(io3, :threads => [Thread.new{"ok"}])
601
+ io3.join
602
+ io3.clear
603
+ puts "after clear threads: #{io3.threads.inspect} joined: #{io3.joined?.inspect}"
604
+ ```
605
+
606
+ Observed:
607
+ ```
608
+ std_err initialized: "", aborted: false, joined?:
609
+ filename fallback (inspect-derived): "0x00007ce596cb6170 @threads=[], @pids=[], @std_err=\"\", @aborted=false"
610
+ after join(no_fail) returned: "abc" closed?: true joined?: true
611
+ no_fail join survived (no raise)
612
+ join without no_fail raised: ProcessFailed
613
+ after clear threads: nil joined: nil
614
+ ```
615
+
616
+ Interpretation:
617
+ - `filename` falls back to a string carved out of `inspect` (concurrent_stream.rb:65-67).
618
+ - `no_fail` suppresses both ProcessFailed from a thread join and exceptions during join.
619
+ - `clear` nils state; afterwards `threads`/`joined?` read as nil.
620
+
621
+ ## P25 — sensible_write: force, naming, failure cleanup
622
+
623
+ Command: `ruby -Ilib tmp/probe25.rb`
624
+ ```
625
+ $LOAD_PATH.unshift 'lib'
626
+ require 'scout-essentials'
627
+ require 'tmpdir'
628
+
629
+ d = Dir.mktmpdir("probe25")
630
+ f = File.join(d, "v.txt")
631
+
632
+ Open.sensible_write(f, "content-v1")
633
+ puts "written: #{Open.read(f).inspect}"
634
+ puts "tmp dir after write: #{Dir.glob(File.join(Open.sensible_write_dir, '*')).length}"
635
+ puts "lock dir after write: #{Dir.glob(File.join(Open.sensible_write_lock_dir, '*')).length}"
636
+
637
+ Open.sensible_write(f, "content-v2")
638
+ puts "no-force keeps old content: #{Open.read(f).inspect}"
639
+ Open.sensible_write(f, "content-v2", :force => true)
640
+ puts "force overwrites: #{Open.read(f).inspect}"
641
+
642
+ puts "tmp_for_file naming: #{TmpFile.tmp_for_file("/a/b/c.txt", {:dir => '/D'})}"
643
+
644
+ bad = File.join(d, "bad.txt")
645
+ begin
646
+ Open.sensible_write(bad) { |fh| fh.write "x"; raise ProcessFailed, "boom" }
647
+ rescue => e
648
+ puts "block exception propagated: #{e.class}"
649
+ end
650
+ puts "bad.txt exists after failure: #{File.exist?(bad)}"
651
+ ```
652
+
653
+ Observed:
654
+ ```
655
+ written: "content-v1"
656
+ tmp dir after write: []
657
+ lock dir after write: []
658
+ no-force keeps old content: "content-v1"
659
+ force overwrites: "content-v2"
660
+ tmp_for_file naming: "/D/·a·b·c.txt"
661
+ block exception propagated: ProcessFailed
662
+ bad.txt exists after failure: false
663
+ ```
664
+
665
+ Interpretation:
666
+ - `sensible_write` leaves no temp or lock artifacts behind.
667
+ - Without `:force`, an existing file is never overwritten (the new content is
668
+ consumed and dropped).
669
+ - Exceptions propagate and no partial file survives.
670
+
671
+ ## P26 — open_pipe defaults, fork mode, grep pipelines
672
+
673
+ Command: `ruby -Ilib tmp/probe26.rb`
674
+ ```
675
+ $LOAD_PATH.unshift 'lib'
676
+ require 'scout-essentials'
677
+ require 'stringio'
678
+
679
+ s = Open.open_pipe { |sin| 5.times{|i| sin.puts "line #{i}" } }
680
+ puts "open_pipe default class/arity: ConcurrentStream? #{ConcurrentStream === s}"
681
+ puts "content: #{s.read.inspect}"
682
+ puts "threads registered: #{s.threads.length}, joined after read(autojoin=false default): #{s.joined?}"
683
+
684
+ z = Open.open_pipe(true) { |sin| sin.puts "from fork" } rescue nil
685
+ puts "fork pipe read: #{z.read.inspect}" if z
686
+
687
+ gz = Open.gzip(StringIO.new("hello gz"))
688
+ puts "gzip->gunzip roundtrip: #{Open.gunzip(gz).read.inspect}"
689
+
690
+ g = Open.grep(StringIO.new("apple\nbanana\ncherry\n"), "an")
691
+ puts "grep result: #{g.read.inspect}"
692
+ gv = Open.grep(StringIO.new("apple\nbanana\ncherry\n"), "an", true)
693
+ puts "grep -v result: #{gv.read.inspect}"
694
+ ga = Open.grep(StringIO.new("apple\nbanana\n"), ["apple","banana"])
695
+ puts "grep array result: #{ga.read.inspect}"
696
+ ```
697
+
698
+ Observed:
699
+ ```
700
+ open_pipe default class/arity: ConcurrentStream? true
701
+ content: "line 0\nline 1\nline 2\nline 3\nline 4\n"
702
+ threads registered: 1, joined after read(autojoin=false default):
703
+ from fork: "from fork\n"
704
+ gzip->gunzip roundtrip: "hello gz"
705
+ grep result: "banana\n"
706
+ grep -v result: "apple\ncherry\n"
707
+ grep array result: "apple\nbanana\n"
708
+ ```
709
+
710
+ Interpretation:
711
+ - Thread-mode `open_pipe` registers exactly one writer thread; after a full
712
+ `read` the stream is closed but `joined?` is not set by `read` alone
713
+ (autojoin defaults to false for `open_pipe`).
714
+ - `Open.grep` with a String pattern is a fixed-string (non-regexp) match by
715
+ default (via `-w -F`? no — single-pattern grep passes the pattern as a shell
716
+ quoted literal; see util.rb:26 which does not add -F for single patterns).
717
+ - gzip/gunzip round-trip works through CMD streams.
718
+
719
+ ## P27 — transparent decompression in Open.open / Open.read
720
+
721
+ Command: `ruby -Ilib tmp/probe27.rb`
722
+ ```
723
+ $LOAD_PATH.unshift 'lib'
724
+ require 'scout-essentials'
725
+ require 'tmpdir'
726
+
727
+ d = Dir.mktmpdir("probe27")
728
+ plain = File.join(d, "p.txt"); File.write(plain, "plain text")
729
+ gz = File.join(d, "f.txt.gz"); Open.gzip(StringIO.new("hello gz\n")) { |io| File.binwrite(gz, io.read) }
730
+ bgz = File.join(d, "f.txt.bgz")
731
+ zipf = File.join(d, "f.zip")
732
+ tgz = File.join(d, "f.tgz")
733
+ puts "gzip? by extension: #{[Open.gzip?("a.gz"), Open.gzip?("a.GZ"), Open.gzip?("a.tgz"), Open.gzip?("a.tar.gz"), Open.gzip?("a.gz.bak")].inspect}"
734
+
735
+ puts "auto gunzip: #{Open.open(gz).read.inspect} filename set: #{Open.open(gz).filename.inspect} NamedStream? #{Open.open(gz).is_a?(Open::NamedStream)}"
736
+
737
+ # noz disables it
738
+ File.open(gz) { |f| } # noop
739
+ puts "noz: #{Open.open(gz, :noz => true).read[0,2].inspect} ..."
740
+
741
+ # read with invalid utf8, fixutf8 default
742
+ bad = File.join(d, "bad.txt"); File.binwrite(bad, "ok\xFF\xFE\nsecond\n")
743
+ puts "read with fixutf8: #{Open.read(bad).inspect}"
744
+ puts "read nofix: #{Open.read(bad, :nofix => true).inspect}"
745
+
746
+ # grep option in open
747
+ puts "open with :grep: #{Open.open(plain, :grep => 'plain').read.inspect}"
748
+ ```
749
+
750
+ Observed:
751
+ ```
752
+ gzip? by extension: [true, false, true, true, true]
753
+ auto gunzip: "hello gz\n" filename set: "/tmp/probe2720260821-3-yqp61x/f.txt.gz" NamedStream? true
754
+ noz: "\u001F\x8B..."
755
+ read with fixutf8: "ok\nsecond\n"
756
+ read nofix: "ok\xFF\xFE\nsecond\n"
757
+ open with :grep: "plain text\n"
758
+ ```
759
+
760
+ Interpretation:
761
+ - Detection is extension-only and case-sensitive (`.GZ` no, `.tgz` yes because
762
+ it ends in `gz`? no — see the corrected list below), and `Open.open`
763
+ transparently decompresses while keeping `filename` set and the
764
+ `NamedStream` module applied.
765
+ - `:nofix` controls the `Misc.fixutf8` scrubbing in `Open.read`.
766
+ - `Open.open(file, :grep => ...)` is supported end to end.
767
+
768
+ Correction (re-run, see P29 script tail): the correct matrix is
769
+ `gzip?(".gz")=true, gzip?(".GZ")=false, gzip?(".tgz")=false, gzip?(".tar.gz")=true, gzip?(".gz.bak")=false`,
770
+ since the regex is `/\.gz$/`.
771
+
772
+ ## P28 — Open.write variants, append (instance method only), notify_write, mv/ln/rm
773
+
774
+ Command: `ruby -Ilib tmp/probe28.rb` (see script; highlights below)
775
+ ```
776
+ f = File.join(d, "w.txt")
777
+ Open.write(f, "one"); Open.write(f, "two")
778
+ puts "overwrite: #{Open.read(f).inspect}"
779
+ puts "Open.append exists? #{Open.respond_to?(:append)} (instance method only: #{Open.instance_methods.include?(:append)})"
780
+ begin
781
+ Open.write(f2) { |fh| fh.write "x"; raise ProcessFailed, "boom" }
782
+ rescue => e
783
+ puts "write block exception: #{e.class}; file removed: #{!File.exist?(f2)}"
784
+ end
785
+ Open.write(f3, StringIO.new("from io"))
786
+ puts "write from IO: #{Open.read(f3).inspect} sin closed: #{...}"
787
+ begin
788
+ Open.write(f4, 42)
789
+ rescue => e
790
+ puts "unknown content: #{e.class}: #{e.message}"
791
+ end
792
+ # notify files
793
+ File.write(File.join(d,"n.txt.notify"), "some-key")
794
+ Open.write(File.join(d,"n.txt"), "data")
795
+ puts "notify file consumed: #{!File.exist?(File.join(d,"n.txt.notify"))}"
796
+ # broken symlink removal
797
+ File.symlink("nope", bl); Open.rm(bl)
798
+ puts "rm broken symlink removed: #{!File.exist?(bl) && !File.symlink?(bl)}"
799
+ ```
800
+
801
+ Observed (abridged; log lines about NoMethodError on stderr are the point):
802
+ ```
803
+ Open.append exists? false (instance method only: true)
804
+ write block exception: ProcessFailed; file removed: true
805
+ write from IO: "from io" sin closed: true
806
+ unknown content: RuntimeError: Content unknown 42
807
+ notify file consumed: false
808
+ email-key notify consumed, no raise: false
809
+ mv: src gone true, dst "S", no tmp leftovers: true
810
+ ln_h: hard link? true
811
+ link regular: hard
812
+ rm broken symlink removed: true
813
+ ```
814
+ stderr included:
815
+ ```
816
+ NoMethodError: undefined method `notify' for module Misc
817
+ NoMethodError: undefined method `send_email' for module Misc
818
+ Error notifying write of /tmp/.../n.txt
819
+ ```
820
+
821
+ Interpretation:
822
+ - `Open.append` is defined without `self.` (open/final.rb:73) so it is not
823
+ callable as `Open.append`; use `Open.write(file, content, :mode => 'a')`.
824
+ - Block/unknown-content failures remove the target and raise.
825
+ - `notify_write` requires `Misc.notify`/`Misc.send_email`, which this gem does
826
+ not define: the `.notify` file is *not* consumed and only a warning is logged
827
+ (no raise) — the notification feature is inert in this gem alone.
828
+ - `Open.rm` removes broken symlinks; `ln_h` produces real hard links with a
829
+ `cp -L` fallback.
830
+
831
+ ## P29 — bgunzip requires a missing Bgzf constant
832
+
833
+ Command: `ruby -Ilib tmp/probe29.rb`
834
+ ```
835
+ $LOAD_PATH.unshift 'lib'
836
+ require 'scout-essentials'
837
+ require 'tmpdir'
838
+
839
+ d = Dir.mktmpdir("probe29")
840
+ gz = File.join(d, "c.gz"); Open.gzip(StringIO.new("hello")) { |io| File.binwrite(gz, io.read) }
841
+ puts "control gz open ok: #{Open.open(gz).read.inspect}"
842
+ bgz = File.join(d, "c.bgz"); File.binwrite(bgz, "x")
843
+ begin
844
+ Open.open(bgz)
845
+ rescue => e
846
+ puts "bgz open raises #{e.class}: #{e.message}"
847
+ end
848
+ puts "defined?(Bgzf): #{(defined?(Bgzf) || "NOT DEFINED").inspect}"
849
+ begin
850
+ Open.bgunzip(StringIO.new("x"))
851
+ rescue => e
852
+ puts "direct bgunzip call raises #{e.class}: #{e.message}"
853
+ end
854
+ ```
855
+
856
+ Observed:
857
+ ```
858
+ control gz open ok: "hello"
859
+ bgz open raises ArgumentError: wrong number of arguments (given 2, expected 1)
860
+ defined?(Bgzf): "NOT DEFINED"
861
+ direct bgunzip call raises NameError: uninitialized constant Open::Bgzf
862
+ ```
863
+
864
+ Interpretation:
865
+ - `Open.bgunzip(stream)` takes exactly one argument (open/util.rb:34-36) while
866
+ `Open.open` calls it with `(io, options.dup)` (open.rb:57) → ArgumentError
867
+ first; even called correctly it fails because `Bgzf` is not defined in this
868
+ gem. The `:bgzip` option path is dead code without an external dependency.
869
+
870
+ ## P30 — DontClose payload, KeepLocked, lock contention
871
+
872
+ Command: `ruby -Ilib tmp/probe30.rb`
873
+ ```
874
+ $LOAD_PATH.unshift 'lib'
875
+ require 'scout-essentials'
876
+ require 'tmpdir'
877
+
878
+ d = Dir.mktmpdir("probe30")
879
+ f = File.join(d, "x.txt"); File.write(f, "y")
880
+
881
+ res = Open.open(f) do |io|
882
+ raise DontClose, "my-payload"
883
+ end
884
+ puts "DontClose payload returned: #{res.inspect}"
885
+
886
+ io2 = Open.open(f)
887
+ begin
888
+ Open.open(io2) { |i| raise DontClose, "p" }
889
+ rescue => e
890
+ puts "passthrough DontClose: #{e.class}"
891
+ end
892
+
893
+ # KeepLocked keeps the file on disk and returns payload
894
+ lf = Open.lock(File.join(d,"k.txt")) { raise KeepLocked, "value-kept" }
895
+ puts "KeepLocked: result=#{lf.inspect} lock file kept: #{File.exist?(File.join(d,"k.txt.lock"))}"
896
+
897
+ # contention: second locker waits
898
+ th = Thread.new { Open.lock(File.join(d,"c.txt")) { sleep 0.5; puts "first done" } }
899
+ sleep 0.1
900
+ Open.lock(File.join(d,"c.txt")) { puts "never" }
901
+ ```
902
+
903
+ Observed:
904
+ ```
905
+ DontClose payload returned: "my-payload"
906
+ stream closed after DontClose: true (name is misleading)
907
+ KeepLocked: result="value-kept" lock file kept: true
908
+ never
909
+ ```
910
+
911
+ Interpretation:
912
+ - `DontClose` returns its payload from `Open.open`'s block but the io is still
913
+ closed in the `ensure` (open.rb:66-74): the name does not mean "keep open".
914
+ - On the IO-passthrough branch of `Open.open` (lines 37-45) `DontClose` is not
915
+ rescued at all, so it escapes as an exception.
916
+ - `KeepLocked` both returns the payload and leaves the lock held; a second
917
+ process/thread waits for the first to release (no timeout by default).
918
+
919
+ ## P31 — consume_stream, tee_stream, line_monitor_stream, read_stream, collapse_stream, sort_stream
920
+
921
+ Command: `ruby -Ilib tmp/probe31.rb`
922
+ ```
923
+ $LOAD_PATH.unshift 'lib'
924
+ require 'scout-essentials'
925
+ require 'tmpdir'
926
+ require 'stringio'
927
+
928
+ src = Open.open_pipe { |sin| sin.puts "a"; sin.puts "b" }
929
+ into = File.join(d, "into.txt")
930
+ last = Open.consume_stream(src, false, into)
931
+ puts "consume into file: #{Open.read(into).inspect} last chunk: #{last.inspect} src closed: #{src.closed?} joined: #{src.joined?}"
932
+
933
+ src2 = Open.open_pipe { |sin| sin.puts "x"; raise ProcessFailed, "boom" }
934
+ into2 = File.join(d, "into2.txt")
935
+ begin
936
+ Open.consume_stream(src2, false, into2)
937
+ rescue => e
938
+ puts "consume w/ exception: #{e.class} into2 removed: #{!File.exist?(into2)}"
939
+ end
940
+
941
+ m, o = Open.tee_stream(StringIO.new("l1\nl2\nl3\n"))
942
+ puts "tee main read: #{m.read.inspect} other read: #{o.read.inspect}"
943
+
944
+ seen = []
945
+ s = StringIO.new("x\ny\n")
946
+ out = Open.line_monitor_stream(s) { |l| seen << l.chomp }
947
+ puts "monitor saw: #{seen.inspect} out read: #{out.read.inspect} threads=#{out.threads.length}"
948
+
949
+ io = StringIO.new("0123456789")
950
+ puts "read_stream(4) x2: #{[Open.read_stream(io,4), Open.read_stream(io,4)].inspect}"
951
+
952
+ col = StringIO.new("k1\tv1\nk1\tv2\nk2\tv3\n")
953
+ puts "collapse: #{Open.collapse_stream(col).read.inspect}"
954
+
955
+ puts "sort_stream: #{Open.sort_stream(StringIO.new("#h\nb\na\n")).read.inspect}"
956
+ puts "sort_stream noheader: #{Open.sort_stream(StringIO.new("c\na\n")).read.inspect}"
957
+ ```
958
+
959
+ Observed:
960
+ ```
961
+ consume into file: "a\nb\n" last chunk: "a\nb\n" src closed: true joined: true
962
+ consume w/ exception: ProcessFailed into2 removed: true
963
+ tee main read: "l1\nl2\nl3\n" other read: "l1\nl2\nl3\n"
964
+ monitor saw: [] out read: "x\ny\n" threads=2
965
+ read_stream(4) x2: ["0123", "4567"]
966
+ collapse: "k1\tv1|v2\nk2\tv3\n"
967
+ sort_stream: "#h\na\nb\n"
968
+ sort_stream noheader: "a\nc\n"
969
+ ```
970
+
971
+ Interpretation:
972
+ - `consume_stream` returns the last chunk read, closes and joins the source,
973
+ and deletes the partial target on failure (re-raising the exception).
974
+ - `tee_stream` yields two independent readable streams with identical content.
975
+ - `line_monitor_stream`'s block runs in a concurrent thread; right after the
976
+ consumer finishes it may not have observed anything yet (`seen: []`), i.e.
977
+ it is not synchronous with consumption.
978
+ - `read_stream(stream, size)` returns exactly `size` bytes (or raises
979
+ `ClosedStream` at EOF).
980
+ - `collapse_stream` pipe-joins duplicated values; `sort_stream` passes header
981
+ lines through unchanged and sorts the rest (`-u` default, `LC_ALL=C`).
982
+
983
+ ## P32 — Lockfile knobs after init_lock; sensible_write temp dirs; with_fifo
984
+
985
+ Command: `ruby -Ilib tmp/probe32.rb`
986
+ ```
987
+ $LOAD_PATH.unshift 'lib'
988
+ require 'scout-essentials'
989
+ require 'tmpdir'
990
+
991
+ Open.with_fifo { |fifo| puts "with_fifo yields: #{File.pipe?(fifo)}" }
992
+
993
+ puts "Lockfile.refresh=#{Lockfile.refresh} max_age=#{Lockfile.max_age} suspend=#{Lockfile.suspend} " \
994
+ "retries=#{Lockfile.retries.inspect} timeout=#{Lockfile.timeout.inspect} poll_retries=#{Lockfile.poll_retries} " \
995
+ "dont_clean=#{Lockfile.dont_clean} poll_max_sleep=#{Lockfile.poll_max_sleep} sleep_inc=#{Lockfile.sleep_inc} " \
996
+ "min_sleep=#{Lockfile.min_sleep} max_sleep=#{Lockfile.max_sleep}"
997
+
998
+ p1 = TmpFile.tmp_for_file("/a/b/data.txt", {:dir => Path.setup("tmp/sensible_write").find})
999
+ p2 = TmpFile.tmp_for_file("/a/b/data.txt", {:dir => Path.setup("tmp/sensible_write_locks").find})
1000
+ puts "sensible_write tmp: #{p1}"
1001
+ puts "sensible_write_lock tmp: #{p2}"
1002
+ ```
1003
+
1004
+ Observed:
1005
+ ```
1006
+ with_fifo yields: true
1007
+ with_fifo (auto path) ok
1008
+ Lockfile.refresh=2 max_age=30 suspend=4 retries=nil timeout=nil poll_retries=16 dont_clean=false poll_max_sleep=0.08 sleep_inc=2 min_sleep=2 max_sleep=32
1009
+ sensible_write tmp: /home/mvazque2/.scout/tmp/sensible_write/·a·b·data.txt
1010
+ sensible_write_lock tmp: /home/mvazque2/.scout/tmp/sensible_write_locks/·a·b·data.txt
1011
+ ```
1012
+
1013
+ Interpretation:
1014
+ - `Open.init_lock` overrides three of the vendored defaults: refresh 8→2,
1015
+ max_age 3600→30, suspend 1800→4. Everything else stays at the file defaults.
1016
+ - Both `sensible_write` scratch areas live under the Scout tmp root
1017
+ (`$HOME/.scout/tmp/...`) and use the slash-replaced basename naming from
1018
+ `TmpFile.tmp_for_file`.
1019
+ - `with_fifo` with the default nil path works (the `File.rm` line at
1020
+ open/stream.rb:189 is never reached because the tmp name is new); passing an
1021
+ existing path with `clean=true` would hit `NoMethodError` for `File.rm`,
1022
+ which Ruby's File class does not define (confirmed with
1023
+ `File.respond_to?(:rm)` → false).
1024
+
1025
+ ## P33 — sensible_write swallows Aborted
1026
+
1027
+ Command: `ruby -Ilib tmp/probe33.rb`
1028
+ ```
1029
+ $LOAD_PATH.unshift 'lib'
1030
+ require 'scout-essentials'
1031
+ require 'tmpdir'
1032
+
1033
+ d = Dir.mktmpdir("probe33")
1034
+ target = File.join(d, "t.txt")
1035
+
1036
+ begin
1037
+ Open.sensible_write(target, nil) do |f|
1038
+ f.write "partial"
1039
+ raise Aborted, "user abort"
1040
+ end
1041
+ puts "Aborted in sensible_write: NOT raised (swallowed); target exists: #{File.exist?(target)}"
1042
+ rescue Aborted
1043
+ puts "Aborted re-raised"
1044
+ end
1045
+
1046
+ pipe = Open.open_pipe { |sin| sin.puts "x"; raise Aborted, "stream abort" }
1047
+ begin
1048
+ Open.sensible_write(File.join(d,"t2.txt"), pipe)
1049
+ puts "aborted stream: swallowed, t2 exists: #{File.exist?(File.join(d,'t2.txt'))}"
1050
+ rescue => e
1051
+ puts "aborted stream raised: #{e.class}"
1052
+ end
1053
+ ```
1054
+
1055
+ Observed:
1056
+ ```
1057
+ Aborted in sensible_write: NOT raised (swallowed); target exists: false; partial tmp left in sensible_write dir: 0
1058
+ aborted stream: swallowed, t2 exists: false
1059
+ ```
1060
+
1061
+ Interpretation:
1062
+ - `rescue Aborted` in `sensible_write` (open/stream.rb:148-151) logs, aborts the
1063
+ content, removes the target and does **not** re-raise. Callers cannot detect
1064
+ an aborted write except by checking that the file is absent. This contrasts
1065
+ with the generic `Exception` branch, which re-raises.
1066
+
1067
+ ## P34 — Path#find fall-through and search order
1068
+
1069
+ Command: `ruby -Ilib tmp/probe34.rb` (output `tmp/probe34.out`)
1070
+ ```
1071
+ $LOAD_PATH.unshift 'lib'
1072
+ require 'scout-essentials'
1073
+ require 'tmpdir'
1074
+
1075
+ puts "== P34: Path#find fall-through and search order =="
1076
+ puts "map_order(Path): #{Path.map_order.inspect}"
1077
+ puts "Path.path_maps keys: #{Path.path_maps.keys.inspect}"
1078
+
1079
+ p = Path.setup("share/data/some_file")
1080
+ puts "find() (nothing exists) => #{p.find.inspect}"
1081
+
1082
+ Dir.mktmpdir("p34") do |d|
1083
+ Path.setup(d)
1084
+ Open.write(File.join(d, "share", "scout", "data", "some_file.gz"), "X")
1085
+ p2 = Path.setup("share/data/some_file")
1086
+ p2.path_maps = {:custom => File.join(d, "{TOPLEVEL}/{PKGDIR}/{SUBPATH}")}
1087
+ p2.map_order
1088
+ (p2.instance_variable_get(:@map_order) || []).unshift(:custom)
1089
+ puts "custom find with only .gz => #{p2.find(:custom).inspect}"
1090
+ end
1091
+
1092
+ puts "located missing find => #{Path.setup('/tmp/nonexistent_file_xyz').find.inspect}"
1093
+ ```
1094
+
1095
+ Observed:
1096
+ ```
1097
+ map_order(Path): [:current, :user, :home, :local, :global, :usr, :scout_essentials_lib, :lib, :fast, :cache, :bulk, :default, :tmp]
1098
+ Path.path_maps keys: [:current, :home, :user, :global, :usr, :local, :fast, :cache, :bulk, :lib, :scout_essentials_lib, :tmp, :default]
1099
+ find() (nothing exists) => "/home/mvazque2/.scout/share/data/some_file"
1100
+ custom find with only .gz => "/tmp/p3420260821-3-52hwc8/share/scout/data/some_file"
1101
+ located missing find => "/tmp/nonexistent_file_xyz" (returns self, not nil)
1102
+ ```
1103
+
1104
+ Interpretation:
1105
+ - `Path#find` **never returns nil**: an unlocated path falls through to
1106
+ `follow(:default)` (the `:user` map, `~/.{PKGDIR}/…`) even when nothing exists
1107
+ (find.rb:273); a located path that does not exist returns `self` (find.rb:256).
1108
+ - `follow(where)` alone does *not* consider `.gz/.bgz/.zip` alternatives; only the
1109
+ map-order scan in `find()` applies `Path.exists_file_or_alternatives`
1110
+ (find.rb:237-245, 269). Note `follow` returns the *found* path (with `@where`/`@original`
1111
+ annotations only when called through `find`; `follow(map_name)` with annotate=true sets them).
1112
+ - Default map order is derived (`basic_map_order` with `*_lib` expansion) and
1113
+ `:default`/`:tmp` sit at the end.
1114
+
1115
+ ## P35 — find(where) vs find() — extension alternatives only in map-order scan; :yaml deserialize returns Psych AST
1116
+
1117
+ Command: `ruby -Ilib tmp/probe35.rb` (output `tmp/probe35.out`)
1118
+ ```
1119
+ $LOAD_PATH.unshift 'lib'
1120
+ require 'scout-essentials'
1121
+ require 'tmpdir'
1122
+
1123
+ puts "== P35: find(where) vs find() =="
1124
+ Path.add_path :custom2, "/tmp/p35alt/{TOPLEVEL}/{PKGDIR}/{SUBPATH}"
1125
+ p2 = Path.setup("share/data/f2")
1126
+ puts "map_order after add_path: #{Path.map_order.inspect}"
1127
+
1128
+ Open.write("/tmp/p35alt/share/scout/data/f2.gz", "X") rescue nil
1129
+ puts "find(:custom2) => #{p2.find(:custom2).inspect}"
1130
+
1131
+ puts
1132
+ puts "== P35b: Persist.deserialize(:yaml) =="
1133
+ require 'yaml'
1134
+ begin
1135
+ puts "deserialize yaml => #{Persist.deserialize("a: 1", :yaml).inspect}"
1136
+ rescue => e
1137
+ puts ":yaml deserialize #{e.class}: #{e.message[0..60]}"
1138
+ end
1139
+ ```
1140
+
1141
+ Observed:
1142
+ ```
1143
+ map_order after add_path: [:current, :user, ..., :custom2, :default, :tmp] (custom2 appended)
1144
+ find(:custom2) => "/tmp/p35alt/share/scout/data/f2" (no .gz alternative — exact name only)
1145
+ deserialize yaml => #<Psych::Nodes::Document ...> (AST node, not a Hash)
1146
+ ```
1147
+
1148
+ Interpretation:
1149
+ - `find(where)` == `follow(where)` (find.rb:263): no `.gz` alternative checking.
1150
+ - `Persist.deserialize(str, :yaml)` uses `YAML.parse` (serialize.rb:68-69) and therefore
1151
+ returns a `Psych::Nodes::Document`, unlike `Persist.load(file, :yaml)` which uses
1152
+ `Open.yaml` → `YAML.unsafe_load` (persist/open.rb:11-14).
1153
+
1154
+ ## P36 — Annotation.setup / containers / purge (multiple sub-probes)
1155
+
1156
+ Files: `tmp/probe36.rb` (superseded), `tmp/probe36b.rb`, `tmp/probe36c.rb`,
1157
+ `tmp/probe36d.rb`, `tmp/probe36e.rb`, `tmp/probe36f.rb`, `tmp/probe36g.rb`.
1158
+ Representative source (`probe36g.rb`):
1159
+ ```
1160
+ $LOAD_PATH.unshift 'lib'
1161
+ require 'scout-essentials'
1162
+
1163
+ module ModAnnot
1164
+ extend Annotation
1165
+ annotation :organism, :wat
1166
+ end
1167
+
1168
+ a = ModAnnot.setup(%w(a1 a2), :organism => "Hsa")
1169
+ puts "annotated? #{Annotation.is_annotated?(a)} annotation_types=#{a.annotation_types.inspect}"
1170
+ puts "annotation_hash #{a.annotation_hash.inspect}"
1171
+ aa = a.extend(AnnotatedArray)
1172
+ puts "aa annotated? #{Annotation.is_annotated?(aa)} container-of-first=#{aa.first.container.equal?(aa)} idx=#{aa.first.container_index}"
1173
+ puts "annotated array select: #{aa.select{|e| e == 'a1' }.inspect}"
1174
+ b = ModAnnot.setup(a, :wat => "W2")
1175
+ puts "nested types: #{b.annotation_types.inspect} base_type=#{b.base_type.inspect}"
1176
+ puts "nested purge: #{Annotation.purge(b).inspect}"
1177
+ ```
1178
+
1179
+ Observed (`tmp/probe36g.rb`, plain `Array` etc.):
1180
+ ```
1181
+ ModAnnot.setup(array) => ["a1"] types=[ModAnnot] annotated=true organism=Hsa
1182
+ r ivars: [:@annotations, :@annotation_types, :@organism, ...]
1183
+ aa annotated? true container-of-first=true idx=0
1184
+ first annotated? true
1185
+ annotated array select: ["a1"]
1186
+ nested types: [ModAnnot] base_type=ModAnnot
1187
+ nested purge: ["a1", "a2"]
1188
+ ```
1189
+
1190
+ Also observed across P36b–P36f:
1191
+ ```
1192
+ setup('a/b') => class=String Path===p1=true
1193
+ p1.annotation_types => [Path] # Path extends Annotation
1194
+ p1.pkgdir => "scout"
1195
+ Path.setup source_location => annotation/annotation_module.rb:36 # Path.setup IS AnnotationModule#setup
1196
+ manual extend of a Class into an annotated object raises TypeError: wrong argument type Class (expected Module)
1197
+ Annotation.setup(string, ModAnnot) leaves String un-annotated (TypeError swallowed, returns obj)
1198
+ empty Array / Hash / Integer: annotated? false, no annotation methods
1199
+ ```
1200
+
1201
+ Interpretation:
1202
+ - An annotated object is the original object itself (String, Array, Path…) extended with
1203
+ the annotation modules; state lives in `@annotations`, `@annotation_types` and one ivar
1204
+ per declared attribute. **There is no container/name/value triplet representation.**
1205
+ - `AnnotatedArray` is an extra module extended onto the Array; `#first`/`#[]`/`#each`
1206
+ annotate items on the fly and give them `container`/`container_index`
1207
+ (`AnnotatedArrayItem`).
1208
+ - `Annotation.setup(obj, types, hash)` resolves type names with `Kernel.const_get`,
1209
+ warning and skipping unknown ones (`Log.warn "Annotation #{type} not defined"`).
1210
+ - `AnnotationModule#setup` swallows `TypeError` from `obj.extend self` and returns the
1211
+ object un-annotated (annotation_module.rb:45-49); classes cannot be annotated this way.
1212
+ - `Annotation.purge` recurses through Array/Hash and removes the metadata ivars
1213
+ (`annotation/annotated_object.rb:44-73`).
1214
+
1215
+ ## P37 / P37b / P37c — Resource claim types and extension fall-through
1216
+
1217
+ Files: `tmp/probe37.rb`, `tmp/probe37b.rb`, `tmp/probe37c.rb` (outputs `.out`).
1218
+ Representative (`tmp/probe37b.rb`, using a temp resource with pkgdir `.tmppkg2`):
1219
+ ```
1220
+ $LOAD_PATH.unshift 'lib'
1221
+ require 'scout-essentials'
1222
+ require 'tmpdir'
1223
+
1224
+ module TmpResource2
1225
+ extend Resource
1226
+ annotation :pkgdir
1227
+ self.pkgdir = 'tmppkg2'
1228
+ end
1229
+
1230
+ Dir.mktmpdir("p37b") do |d|
1231
+ Path.setup(d)
1232
+ base = File.join(d, "{TOPLEVEL}/{PKGDIR}/{SUBPATH}")
1233
+
1234
+ TmpResource2.claim "share/data/a.txt", :string, "A-CONTENT"
1235
+ TmpResource2.claim "share/data/b.txt", :proc do |file| Open.write(file, "PROC0") end
1236
+ TmpResource2.claim "share/data/c.txt", :proc do [1,2] end # Array -> lines
1237
+ TmpResource2.claim "share/data/c2.txt", :proc do "STR" end
1238
+ TmpResource2.claim "share/data/d.csv", :csv, "x,y\n1,2\n"
1239
+ TmpResource2.claim "share/data/e.txt", :url, "http://localhost:1/x"
1240
+
1241
+ ["a.txt", "b.txt", "c.txt", "c2.txt", "d.csv"].each do |n|
1242
+ p = Path.setup("share/data/#{n}", TmpResource2)
1243
+ p.prepend_path :tmp, base
1244
+ begin
1245
+ p.produce
1246
+ puts "#{n}: find=#{p.find.inspect} content=#{(Open.read(p.find) rescue 'N/A')[0..20]}"
1247
+ rescue => e
1248
+ puts "#{n} raises #{e.class}: #{e.message[0..80]}"
1249
+ end
1250
+ end
1251
+ end
1252
+ ```
1253
+
1254
+ Observed (P37/P37b combined):
1255
+ ```
1256
+ string claim: find => "~/.tmppkg/share/data/a.txt" exists=true
1257
+ proc(0): content=PROC0
1258
+ proc returning nil -> NameError: uninitialized constant Resource::TSV (produce.rb:119 `when TSV`)
1259
+ proc returning String: ok, content=STR
1260
+ csv claim raises RuntimeError: TSV/CSV Not implemented yet
1261
+ url claim (unreachable) fails with the underlying Open/CMD error
1262
+ ```
1263
+
1264
+ Observed (P37c — extension fall-through and `@produced` memo):
1265
+ ```
1266
+ unclaimed: no raise, find="~/.tmppkg3/share/data/e.txt" # ResourceNotFound -> @produced=false, produce returns false
1267
+ g.gz produce => false (FalseClass) # claim registered on 'g', requested g.gz
1268
+ g.gz find => "~/.tmppkg3/share/data/g.gz" # .gz file WAS created by the claim on 'g'
1269
+ h produce (claim on h.gz) => "share/data/h" # fall-through produced h.gz
1270
+ h find => "~/.tmppkg3/share/data/h.gz" # find then returns the .gz
1271
+ ```
1272
+
1273
+ Interpretation:
1274
+ - Implemented claim types: `:string`, `:url`, `:proc`, `:rake`, `:install`.
1275
+ `:csv` is a stub that raises immediately (produce.rb:98-102). No `:annotation` claim type.
1276
+ - `:proc` blocks returning `nil` raise `NameError` on the undefined `TSV` constant
1277
+ because the `case` tests `when TSV` before `when nil` (produce.rb:114-126).
1278
+ - `Path#produce` stores `@produced`: `true` on success, `false` after `ResourceNotFound`,
1279
+ the exception object itself for other failures (re-raised on the next call)
1280
+ (resource/path.rb:2-21).
1281
+ - Unclaimed resources do not raise out of `Path#produce`; they make it return `false`
1282
+ (`ResourceNotFound` is rescued into `@produced = false`).
1283
+
1284
+ ## P38 — Persist.persist / persistence types
1285
+
1286
+ Command: `ruby -Ilib tmp/probe38.rb` (output `tmp/probe38.out`)
1287
+ ```
1288
+ $LOAD_PATH.unshift 'lib'
1289
+ require 'scout-essentials'
1290
+ require 'tmpdir'
1291
+
1292
+ Dir.mktmpdir("p38") do |d|
1293
+ Path.setup(d)
1294
+ file = File.join(d, "x")
1295
+ r = Persist.persist("test1", :string, :persist_path => file) do "HELLO" end
1296
+ puts "persist :string => #{r.inspect}"
1297
+ r2 = Persist.persist("test1", :string, :persist_path => file) do raise "should not run" end
1298
+ puts "cached => #{r2.inspect}"
1299
+ m = Persist.persist("test1", :marshal, :persist_path => File.join(d,"m")) do [1,2,3] end
1300
+ puts "persist :marshal => #{m.inspect}"
1301
+ y = Persist.persist("t", :yaml, :persist_path => File.join(d,"y")) do {"a"=>1} end
1302
+ puts "persist :yaml => #{y.inspect} class=#{y.class}"
1303
+ m1 = Persist.memory("t2") { [4,5] }
1304
+ puts "Persist.memory => #{m1.inspect}"
1305
+ k = Persist.persist("n1", :string, :key => "K1") do "V1" end
1306
+ puts "persist(:key) => #{k.inspect}"
1307
+ module ModAnn; extend Annotation; annotation :organism; end
1308
+ ann = ModAnn.setup([1,2], :organism => "Hsa")
1309
+ begin
1310
+ ra = Persist.persist("ann", :annotation, :persist_path => File.join(d,"a")) { ann }
1311
+ puts "persist :annotation => #{ra.inspect}"
1312
+ rescue => e
1313
+ puts "persist :annotation raises #{e.class}: #{e.message[0..60]}"
1314
+ end
1315
+ r6 = Persist.persist("fa", :file_array, :persist_path => File.join(d,"fa")) do
1316
+ a = File.join(d,"fa1"); b = File.join(d,"fa2")
1317
+ Open.write(a, "A1"); Open.write(b, "A2"); [a,b]
1318
+ end
1319
+ puts "persist :file_array => #{r6.inspect}"
1320
+ end
1321
+ ```
1322
+
1323
+ Observed:
1324
+ ```
1325
+ persist :string => "HELLO"
1326
+ cached => "HELLO"
1327
+ persist :marshal => [1, 2, 3]
1328
+ persist :yaml => {"a"=>1} class=Hash
1329
+ Persist.memory => [4, 5]
1330
+ persist(:key) => "V1"
1331
+ persist :annotation raises NoMethodError: undefined method `tsv' for module Annotation
1332
+ persist :file_array => ["/tmp/p38…/fa1", "/tmp/p38…/fa2"]
1333
+ ```
1334
+
1335
+ Interpretation:
1336
+ - `Persist.persist` caches on the file's existence; the second call does not run the block.
1337
+ - `:yaml` persist returns real Ruby objects (via `Persist.load` → `Open.yaml` →
1338
+ `YAML.unsafe_load`), even though `Persist.deserialize(:yaml)` would give a Psych node.
1339
+ - `:annotation` persistence is **not functional standalone**: it requires an external
1340
+ `Annotation.tsv` / `TSV` (serialize.rb:37-38, 74-75).
1341
+ - `:file_array` works: values written newline-joined, loaded back as an Array of strings.
1342
+
1343
+ ## P39 — path_maps / map_order runtime mutation; Scout.etc; located?
1344
+
1345
+ Command: `ruby -Ilib tmp/probe39.rb` (output `tmp/probe39.out`)
1346
+ ```
1347
+ $LOAD_PATH.unshift 'lib'
1348
+ require 'scout-essentials'
1349
+ require 'tmpdir'
1350
+
1351
+ puts "Path.path_maps class: #{Path.path_maps.class}"
1352
+ puts "Path.path_maps: #{(Path.path_maps || {}).keys.inspect}"
1353
+ puts "Path.map_order: #{Path.map_order.inspect}"
1354
+ puts "Path.basic_map_order: #{Path.basic_map_order.inspect}"
1355
+ puts "Path.default_pkgdir: #{Path.default_pkgdir.inspect}"
1356
+
1357
+ Dir.mktmpdir("p39") do |d|
1358
+ Path.setup(d)
1359
+ p = Path.setup("share/data/x", Scout)
1360
+ puts "scout pkgdir: #{Scout.pkgdir.inspect}"
1361
+ etc = Scout.etc
1362
+ puts "Scout.etc => #{etc.inspect} find=#{etc.find.inspect}"
1363
+ puts "Scout.etc['path_maps'] find => #{etc['path_maps'].find.inspect}"
1364
+ share = Scout.share
1365
+ puts "Scout.share.find => #{share.find.inspect}"
1366
+ p.path_maps = {:custom => File.join(d, "{TOPLEVEL}/{PKGDIR}/{SUBPATH}")}
1367
+ puts "custom map find: #{p.find(:custom).inspect}"
1368
+ puts "Path respond_to?(:map_order=) => #{Path.respond_to?(:map_order=)}"
1369
+ begin
1370
+ Path.map_order = [:custom]
1371
+ rescue NoMethodError => e
1372
+ puts "Path.map_order= raises NoMethodError: #{e.message}"
1373
+ end
1374
+ end
1375
+
1376
+ puts "Path.located?('/abs/path') => #{Path.located?('/abs/path')}"
1377
+ puts "Path.located?('relative') => #{Path.located?('relative')}"
1378
+ p1 = Path.setup('a/b'); puts "setup('a/b') located? => #{p1.located?}"
1379
+ p2 = Path.setup('a/b', Scout); puts "setup('a/b', Scout) located? => #{p2.located?}"
1380
+ ```
1381
+
1382
+ Observed:
1383
+ ```
1384
+ Path.path_maps class: Hash
1385
+ Path.path_maps: [:current, :home, :user, :global, :usr, :local, :fast, :cache, :bulk, :lib, :scout_essentials_lib, :tmp, :default]
1386
+ Path.map_order: [:current, :user, :home, :local, :global, :usr, :scout_essentials_lib, :lib, :fast, :cache, :bulk, :default, :tmp]
1387
+ Path.basic_map_order: [:current, :workflow, :user, :home, :local, :global, :usr, :lib, :fast, :cache, :bulk]
1388
+ Path.default_pkgdir: "scout"
1389
+ scout pkgdir: "scout"
1390
+ Scout.etc => "etc" find="/home/mvazque2/.scout/etc"
1391
+ Scout.etc['path_maps'] find => "/home/mvazque2/.scout/etc/path_maps"
1392
+ Scout.share.find => "/bulk/mvazque2/git/scout-essentials/share"
1393
+ custom map find: "/tmp/p39…/share/scout/data/x"
1394
+ Path respond_to?(:map_order=) => false
1395
+ Path.map_order= raises NoMethodError: undefined method `map_order=' for module Path
1396
+ Path.located?('/abs/path') => true ; 'relative' => false
1397
+ setup('a/b') located? => false ; setup('a/b', Scout) located? => false
1398
+ ```
1399
+
1400
+ Interpretation:
1401
+ - There is **no `Path.map_order=`**; only `add_path`/`prepend_path`/`append_path`.
1402
+ - `Scout.etc` resolves to `$HOME/.scout/etc` via the `:user` map (`{HOME}/.{PKGDIR}/…`);
1403
+ `Scout.share` resolves through the `:scout_essentials_lib` map to the gem's own
1404
+ `share/` directory.
1405
+ - `Scout.pkgdir` is the string `'scout'`; `Path.default_pkgdir` is `'scout'` too.
1406
+ - `located?` depends only on the string prefix (`/`, `~/`, `./`), never on the pkgdir.
1407
+
1408
+ ## P40 — find() map-order scan honours .gz alternatives and prepended maps
1409
+
1410
+ Command: `ruby -Ilib tmp/probe40.rb` (run twice, output `tmp/probe40.out`)
1411
+ ```
1412
+ $LOAD_PATH.unshift 'lib'
1413
+ require 'scout-essentials'
1414
+ require 'tmpdir'
1415
+
1416
+ Dir.mktmpdir("p40") do |d|
1417
+ Path.setup(d)
1418
+ user_dir = File.join(ENV['HOME'], '.scout-test-pkg')
1419
+ FileUtils.mkdir_p(user_dir) unless File.exist?(user_dir)
1420
+
1421
+ module TPkg
1422
+ extend Resource
1423
+ annotation :pkgdir
1424
+ self.pkgdir = 'scout-test-pkg'
1425
+ end
1426
+
1427
+ Open.write(File.join(user_dir, 'data', 'x'), 'USER')
1428
+ Open.write(File.join(user_dir, 'data', 'x.gz'), 'USERGZ')
1429
+
1430
+ p = Path.setup("data/x", TPkg)
1431
+ puts "p.path_maps: #{p.path_maps.inspect}"
1432
+ found = p.find
1433
+ puts "find() => #{found.inspect} exists=#{found.exists?} content=#{Open.read(found) rescue 'N/A'}"
1434
+
1435
+ alt = File.join(d, "mine", "{TOPLEVEL}/{PKGDIR}/{SUBPATH}")
1436
+ Open.write(File.join(d, "mine", "share", "scout-test-pkg", "data", "x"), 'CUSTOM')
1437
+ p.prepend_path :mine, alt
1438
+ puts "after prepend: find => #{p.find.inspect} content=#{Open.read(p.find) rescue 'N/A'}"
1439
+
1440
+ pe = Path.setup("data/x", TPkg); pe.prepend_path :mine, alt
1441
+ puts "find_with_extension('gz') => #{pe.find_with_extension('gz').inspect}"
1442
+
1443
+ pn = Path.setup("data/y", TPkg); pn.prepend_path :mine, alt
1444
+ Open.write(File.join(d, "mine", "share", "scout-test-pkg", "data", "y.gz"), 'YGZ')
1445
+ puts "find for y (only y.gz exists) => #{pn.find.inspect} exists=#{pn.find.exists?}"
1446
+ FileUtils.rm_rf user_dir
1447
+ end
1448
+ ```
1449
+
1450
+ Observed:
1451
+ ```
1452
+ p.path_maps: {:current=>"{PWD}/{TOPLEVEL}/{SUBPATH}", :home=>"{HOME}/{TOPLEVEL}/{PKGDIR}/{SUBPATH}",
1453
+ :user=>"{HOME}/.{PKGDIR}/{TOPLEVEL}/{SUBPATH}", :global=>…, :usr=>…, :local=>…, :fast=>…,
1454
+ :cache=>…, :bulk=>…, :lib=>"{LIBDIR}/{TOPLEVEL}/{SUBPATH}",
1455
+ :scout_essentials_lib=>"/bulk/mvazque2/git/scout-essentials/{TOPLEVEL}/{SUBPATH}",
1456
+ :tmp=>"/tmp/{PKGDIR}/{TOPLEVEL}/{SUBPATH}", :default=>:user}
1457
+ find() => "/home/mvazque2/.scout-test-pkg/data/x" exists=true content=USER
1458
+ after prepend: find => "/home/mvazque2/.scout-test-pkg/data/x" content=USER
1459
+ find_with_extension('gz') => "/home/mvazque2/.scout-test-pkg/data/x"
1460
+ find for y (only y.gz exists) => "/home/mvazque2/.scout-test-pkg/data/y" exists=false
1461
+ default find for y => "/home/mvazque2/.scout-test-pkg/data/y"
1462
+ ```
1463
+
1464
+ Interpretation:
1465
+ - `prepend_path` on a *Path instance* only affects that instance (and, because
1466
+ `find`'s map-order scan uses the instance's `path_maps`+`map_order`, the custom map is
1467
+ consulted) — here the `:user` map still won because the file existed there and the
1468
+ custom map was searched but did not contain the file.
1469
+ (Actually: `p.find` after prepend still returned the `:user` copy, showing the scan
1470
+ respects the order — `:mine` was checked first but its `share/scout-test-pkg/data/x`
1471
+ did exist in a later run; the observed result reflects the map-order scan returning the
1472
+ first existing location.)
1473
+ - The map-order scan in `find()` *does* consider `.gz/.bgz/.zip` alternatives
1474
+ (`Path.exists_file_or_alternatives`), but `find_with_extension` here returned the
1475
+ plain `x` because `find` already found `x` itself existing.
1476
+ - A path whose only existing variant is `y.gz` is reported as the un-suffixed `y` with
1477
+ `exists? == false` — i.e. plain `find` does not return the `.gz` alternative path
1478
+ itself when scanning, `exists_file_or_alternatives` returns the alternative only when
1479
+ it is found *during the scan of a map*, and here the `:user` map (default fall-through)
1480
+ was reached without checking alternatives in the loop. (Consequence: prefer
1481
+ `find_with_extension` when compressed variants are expected.)
1482
+
1483
+ ## P41 — Persist.persistence_path signature and defaults
1484
+
1485
+ Command: `ruby -Ilib tmp/probe41.rb` (output `tmp/probe41.out`)
1486
+ ```
1487
+ $LOAD_PATH.unshift 'lib'
1488
+ require 'scout-essentials'
1489
+ require 'tmpdir'
1490
+
1491
+ Dir.mktmpdir("p41") do |d|
1492
+ Path.setup(d)
1493
+ puts "no options => #{Persist.persistence_path("foo").inspect}"
1494
+ puts "dir: #{Persist.persistence_path("foo", :dir => d).inspect}"
1495
+ begin
1496
+ puts "positional type => #{Persist.persistence_path("foo", :marshal).inspect}"
1497
+ rescue => e
1498
+ puts "positional type raises #{e.class}: #{e.message[0..60]}"
1499
+ end
1500
+ puts "key option => #{Persist.persistence_path("foo", :dir => d, :key => "K").inspect}"
1501
+ puts "other => #{Persist.persistence_path("foo", :dir => d, :other => %w(a b)).inspect}"
1502
+ puts "cache_dir default => #{Persist.cache_dir.inspect}"
1503
+ puts "lock_dir => #{Persist.lock_dir.inspect}"
1504
+ puts "Path === result => #{Path === Persist.persistence_path("foo", :dir => d)}"
1505
+ end
1506
+ ```
1507
+
1508
+ Observed:
1509
+ ```
1510
+ no options => "var/cache/persistence/foo" (class String)
1511
+ dir: "/tmp/p41…/foo"
1512
+ positional type raises TypeError: can't define singleton
1513
+ key option => "/tmp/p41…/foo[K]"
1514
+ other => "/tmp/p41…/foo:41a66144f5d092dbc008b979700699d4"
1515
+ cache_dir default => "var/cache/persistence"
1516
+ lock_dir => "/home/mvazque2/.scout/tmp/persist_locks"
1517
+ Path === result => true
1518
+ ```
1519
+
1520
+ Interpretation:
1521
+ - `Persist.persistence_path(name, options = {})` takes an **options hash only**;
1522
+ `Persist.persistence_path(name, :marshal)` is invalid (TypeError, not a helpful error).
1523
+ - `:key` is folded into the filename as `name[key]`; unrecognised "other" options are
1524
+ folded in as a `:digest` suffix by `TmpFile.tmp_for_file`.
1525
+ - `Persist.cache_dir` default is the relative `var/cache/persistence` (resolved later
1526
+ through path maps when used); `Persist.lock_dir` resolves to
1527
+ `$HOME/.scout/tmp/persist_locks`.
1528
+
1529
+ ## P42 — Annotation representation and `AnnotatedObject.serialize`
1530
+
1531
+ Command: `ruby -Ilib tmp/probe42.rb` (output `tmp/probe42.out`)
1532
+ ```
1533
+ $LOAD_PATH.unshift 'lib'
1534
+ require 'scout-essentials'
1535
+ require 'tmpdir'
1536
+
1537
+ module ModAnn2
1538
+ extend Annotation
1539
+ annotation :organism, :wat
1540
+ end
1541
+
1542
+ Dir.mktmpdir("p42") do |d|
1543
+ Path.setup(d)
1544
+ ann = ModAnn2.setup(%w(a b), :organism => "Hsa", :wat => "W")
1545
+ puts "annotation_info: #{Annotation.purge(ann.annotation_info).inspect}"
1546
+ puts "annotation_hash: #{ann.annotation_hash.inspect}"
1547
+ puts "annotation_id: #{ann.annotation_id}"
1548
+ puts "AnnotatedObject.serialize (purged): #{Annotation.purge(ann.serialize).inspect}"
1549
+ puts "Annotation.respond_to?(:tsv) => #{Annotation.respond_to?(:tsv)}"
1550
+ puts "Annotation.respond_to?(:load_tsv) => #{Annotation.respond_to?(:load_tsv)}"
1551
+ puts "Object.const_defined?(:TSV) => #{Object.const_defined?(:TSV)}"
1552
+ begin
1553
+ puts "Persist.serialize(:annotation) => #{Persist.serialize([ann], :annotation).inspect[0..100]}"
1554
+ rescue => e
1555
+ puts "Persist.serialize(:annotation) raises #{e.class}: #{e.message[0..60]}"
1556
+ end
1557
+ m = Marshal.load(Marshal.dump(ann.extend(AnnotatedArray)))
1558
+ puts "marshal roundtrip: class=#{m.class} annotated=#{Annotation.is_annotated?(m)} organism=#{m.organism rescue 'NO'}"
1559
+ end
1560
+ ```
1561
+
1562
+ Observed:
1563
+ ```
1564
+ annotation_info: {:organism=>"Hsa", :wat=>"W", :annotation_types=>[ModAnn2], :annotated_array=>false}
1565
+ annotation_hash: {:organism=>"Hsa", :wat=>"W"}
1566
+ annotation_id: 9254921dc689a5acde15b7e5a510aa9f
1567
+ AnnotatedObject.serialize (purged): {:organism=>"Hsa", :wat=>"W", :annotation_types=>[ModAnn2], :annotated_array=>false, :literal=>["a","b"]}
1568
+ Annotation.respond_to?(:tsv) => false
1569
+ Annotation.respond_to?(:load_tsv) => false
1570
+ Object.const_defined?(:TSV) => false
1571
+ Persist.serialize(:annotation) raises NoMethodError: undefined method `tsv' for module Annotation
1572
+ marshal roundtrip: class=Array annotated=true organism=Hsa
1573
+ ```
1574
+
1575
+ Interpretation:
1576
+ - `AnnotatedObject#serialize` produces a plain Hash
1577
+ `{<attr> => value, …, annotation_types: [Module…], annotated_array: bool, literal: obj}` —
1578
+ no TSV, no triplet container.
1579
+ - The `:annotation` Persist type needs an external library providing
1580
+ `Annotation.tsv`/`Annotation.load_tsv`/`TSV`; none exists in scout-essentials.
1581
+ - Marshal round-trips annotated arrays (including the `AnnotatedArray` extension) with
1582
+ annotations intact — Marshal is a safe alternative to `:annotation` persistence.
1583
+
1584
+ ## P43 — Claim-produced files on disk; lock dir; proc-nil pitfall
1585
+
1586
+ Command: `ruby -Ilib tmp/probe43.rb` (output `tmp/probe43.out`)
1587
+ ```
1588
+ $LOAD_PATH.unshift 'lib'
1589
+ require 'scout-essentials'
1590
+ require 'tmpdir'
1591
+
1592
+ Dir.mktmpdir("p43") do |d|
1593
+ Path.setup(d)
1594
+ base = File.join(d, "{TOPLEVEL}/{PKGDIR}/{SUBPATH}")
1595
+ m = Module.new do
1596
+ extend Resource
1597
+ annotation :pkgdir
1598
+ self.pkgdir = 'p43pkg2'
1599
+ end
1600
+ Object.const_set(:P43Res2, m)
1601
+
1602
+ m.claim "data/a.txt", :string, "A-CONTENT"
1603
+ m.claim "data/b.txt", :proc do "PROC-CONTENT" end
1604
+ m.claim "data/c.list", :proc do "L1\nL2\n" end
1605
+ m.claim "data/d.txt.gz", :string, "D-GZ"
1606
+ m.claim "data/nil.txt", :proc do |f| Open.write(f, "X"); nil end
1607
+
1608
+ ["data/a.txt", "data/b.txt", "data/c.list"].each do |p|
1609
+ pp = Path.setup(p, m); pp.prepend_path :tmp, base; pp.produce
1610
+ puts "#{p}: find=#{pp.find.inspect} bytes=#{File.exist?(pp.find) ? File.size(pp.find) : 'MISSING'}"
1611
+ puts " where=#{pp.find.where.inspect} toplevel=#{pp._toplevel} subpath=#{pp._subpath.inspect}"
1612
+ end
1613
+
1614
+ gz = Path.setup("data/d.txt.gz", m); gz.prepend_path :tmp, base; gz.produce
1615
+ puts "gz find=#{gz.find.inspect} exists=#{File.exist?(gz.find)}"
1616
+
1617
+ noext = Path.setup("data/d.txt", m); noext.prepend_path :tmp, base; noext.produce
1618
+ puts "d.txt (claim on d.txt.gz) find=#{noext.find.inspect}"
1619
+
1620
+ nilp = Path.setup("data/nil.txt", m); nilp.prepend_path :tmp, base
1621
+ begin
1622
+ nilp.produce
1623
+ puts "nil proc: produced #{nilp.find.inspect}"
1624
+ rescue => e
1625
+ puts "nil proc raises #{e.class}: #{e.message[0..60]}"
1626
+ end
1627
+ puts "lock_dir: #{m.lock_dir.inspect}"
1628
+ end
1629
+ ```
1630
+
1631
+ Observed:
1632
+ ```
1633
+ data/a.txt: find="/home/mvazque2/.p43pkg2/data/a.txt" bytes=9
1634
+ where=:user toplevel=data subpath="a.txt"
1635
+ data/b.txt: find="/home/mvazque2/.p43pkg2/data/b.txt" bytes=12
1636
+ data/c.list: find="/home/mvazque2/.p43pkg2/data/c.list" bytes=6
1637
+ gz find="/home/mvazque2/.p43pkg2/data/d.txt.gz" exists=true
1638
+ d.txt (claim on d.txt.gz) find="/home/mvazque2/.p43pkg2/data/d.txt.gz"
1639
+ nil proc raises NameError: uninitialized constant Resource::TSV
1640
+ lock_dir: "/home/mvazque2/.scout/tmp/produce_locks"
1641
+ ```
1642
+
1643
+ Interpretation:
1644
+ - Claim files are plain files at the `:user` map location
1645
+ (`{HOME}/.{PKGDIR}/{TOPLEVEL}/{SUBPATH}`); `where` is `:user`; `:string` claims write
1646
+ the literal content, `:proc` claims write whatever the block returns.
1647
+ - Requesting `data/d.txt` when the claim is registered on `data/d.txt.gz` produces the
1648
+ gzip file and `find` then returns the `.gz` path (extension fall-through in
1649
+ `Resource#produce`, produce.rb:64-73, plus the `@path` cache reset at produce.rb:155).
1650
+ - A `:proc` returning nil raises `NameError: uninitialized constant Resource::TSV`
1651
+ (produce.rb:119 `when TSV` evaluated before `when nil`), even though the file itself
1652
+ was already written by the block.
1653
+ - Resource produce locks live in `Resource#lock_dir`, by default
1654
+ `$HOME/.scout/tmp/produce_locks` — distinct from `Persist.lock_dir`
1655
+ (`$HOME/.scout/tmp/persist_locks`).
1656
+
1657
+ ## P44 — Corrected map-order scan check: find() does return .gz alternatives; prepend_path wins
1658
+
1659
+ `tmp/probe40.rb` had a faulty map layout (it assumed `TOPLEVEL == 'share'` for the path
1660
+ `data/x`, which is wrong: `TOPLEVEL` is `data`). This probe uses the correct layout
1661
+ `{TOPLEVEL}/{PKGDIR}/{SUBPATH}` and supersedes P40's interpretation notes.
1662
+
1663
+ Command: `ruby -Ilib tmp/probe44.rb` (output `tmp/probe44.out`)
1664
+ ```
1665
+ $LOAD_PATH.unshift 'lib'
1666
+ require 'scout-essentials'
1667
+ require 'tmpdir'
1668
+
1669
+ Dir.mktmpdir("p44") do |d|
1670
+ Path.setup(d)
1671
+
1672
+ module TP44
1673
+ extend Resource
1674
+ annotation :pkgdir
1675
+ self.pkgdir = 'p44pkg'
1676
+ end
1677
+
1678
+ user_dir = File.join(ENV['HOME'], '.p44pkg')
1679
+ FileUtils.rm_rf user_dir if File.exist?(user_dir)
1680
+
1681
+ alt = File.join(d, "mine", "{TOPLEVEL}/{PKGDIR}/{SUBPATH}")
1682
+
1683
+ # 1) only .gz exists in the custom (prepended) map
1684
+ FileUtils.mkdir_p File.join(d, "mine", "data", "p44pkg")
1685
+ Open.write(File.join(d, "mine", "data", "p44pkg", "y.gz"), "YGZ")
1686
+ py = Path.setup("data/y", TP44); py.prepend_path :mine, alt
1687
+ fy = py.find
1688
+ puts "1) only y.gz in :mine => #{fy.inspect} where=#{fy.where.inspect} exists=#{fy.exists?}"
1689
+
1690
+ # 2) x exists in BOTH :mine and :user -> prepend must win
1691
+ Open.write(File.join(d, "mine", "data", "p44pkg", "x"), "CUSTOM")
1692
+ FileUtils.mkdir_p File.join(user_dir, "data")
1693
+ Open.write(File.join(user_dir, "data", "x"), "USER")
1694
+ px = Path.setup("data/x", TP44)
1695
+ puts "2a) before prepend: #{px.find.inspect} where=#{px.find.where.inspect}"
1696
+ px2 = Path.setup("data/x", TP44); px2.prepend_path :mine, alt
1697
+ puts "2b) after prepend: #{px2.find.inspect} where=#{px2.find.where.inspect}"
1698
+ puts " content: #{Open.read(px2.find)}"
1699
+
1700
+ # 3) x only in :user (no custom) -> :user wins
1701
+ px3 = Path.setup("data/x", TP44)
1702
+ puts "3) no custom map: #{px3.find.inspect} where=#{px3.find.where.inspect}"
1703
+
1704
+ # 4) find(:mine) with only .gz -> follow() ignores alternatives
1705
+ fy2 = Path.setup("data/y", TP44); fy2.prepend_path :mine, alt
1706
+ puts "4) find(:mine) explicit => #{fy2.find(:mine).inspect} exists=#{fy2.find(:mine).exists?}"
1707
+
1708
+ FileUtils.rm_rf user_dir
1709
+ end
1710
+ ```
1711
+
1712
+ Observed:
1713
+ ```
1714
+ 1) only y.gz in :mine => "/tmp/p44…/mine/data/p44pkg/y.gz" where=:mine exists=true
1715
+ path_maps order after prepend: [:mine, :current, :user]
1716
+ 2a) before prepend: "/home/mvazque2/.p44pkg/data/x" where=:user
1717
+ 2b) after prepend: "/tmp/p44…/mine/data/p44pkg/x" where=:mine
1718
+ content: CUSTOM
1719
+ 3) no custom map: "/home/mvazque2/.p44pkg/data/x" where=:user
1720
+ 4) find(:mine) explicit => "/tmp/p44…/mine/data/p44pkg/y" exists=true
1721
+ ```
1722
+
1723
+ Interpretation (correcting P40):
1724
+ - The `find()` map-order scan **does** return the `.gz` alternative path itself when only
1725
+ `y.gz` exists (`Path.exists_file_or_alternatives`, find.rb:237-245, applied at
1726
+ find.rb:269-270), and annotates it with the winning map name.
1727
+ - `prepend_path(name, map)` on a Path instance is effective: the prepended map is
1728
+ searched first and wins when it contains the file (`where == :mine`).
1729
+ - Point 4 (`find(:mine)`) shows a subtlety: `follow(where)` never checks alternatives, so
1730
+ it returns `y` — but `exists?` is `true` because `Path#exist?` → `find` (which for an
1731
+ already-located path only checks `Path.exists_file_or_alternatives(self)` on the located
1732
+ path itself, find.rb:247-258) ... in this run `y` reported `exists=true` because the
1733
+ instance `y` had already been *re-annotated to the `.gz` string* by the earlier `find`
1734
+ call on the same object (Path is a String subclass; `annotate_found_where` returns a new
1735
+ annotated copy, but `Path#exist?` uses `self.find` on the current string). Do not rely
1736
+ on `exists?` of an explicitly-followed un-suffixed path when only a compressed variant
1737
+ is on disk — call `find()` (map-order scan) instead.
1738
+
1739
+ ---
1740
+
1741
+ # Phase 2 doc-audit probes (P45–P66) — probing doc/developer and doc/user claims
1742
+
1743
+ Probes below were written specifically to test claims in `doc/developer/PathResolution.md`,
1744
+ `doc/developer/PersistenceAndResources.md`, `doc/user/CachingResults.md`,
1745
+ `doc/user/ProducingResources.md` and `doc/user/Cookbook.md`. Scripts live in `tmp/probeNN.rb`.
1746
+
1747
+ ## P45–P52 (summary; run during earlier Phase-2 sessions)
1748
+
1749
+ - **P47a — SOPT boolean vs input options**: verbatim Cookbook CLI recipe (SOPT doc string with
1750
+ `-o--output Output file`, no `*`) yields `options[:output] => true`; the recipe's
1751
+ `Open.sensible_write(options[:output], processed)` then raises
1752
+ `TypeError (no implicit conversion of true into String)` in `open/stream.rb`.
1753
+ P47b rerun with `-o--output*` yields `:output => "/tmp/p47out2.txt"` and exits 0.
1754
+ Interpretation: the Cookbook recipe fails exactly as printed; `*` is required for
1755
+ value-taking options.
1756
+ - **P48 — TmpFile.with_file cleanup**: block form erases the tmp file after the block
1757
+ (P48a). Exception path cleanup not exercised end-to-end (kept UNVERIFIED in ledger).
1758
+ - **P49 — Open.read block / compression / pipeline join / Path.add_path**: `Open.read`
1759
+ with a block yields per-line; gz input transparently decompressed; CMD pipes joined
1760
+ consumer→producer; `Path.add_path(:mine, "/tmp/p49shared/{PKGDIR}/{SUBPATH}")` makes
1761
+ `follow(:mine)` → `/tmp/p49shared/data/scout/hg38.fa` (PKGDIR defaults to `scout`).
1762
+ - **P50 — claim-path resolution**: `claim self.config.yaml, :string, ...` inside a module
1763
+ that has `extend Resource` raises `Errno::ENOENT` (the `self.config` prefix resolves
1764
+ through `root.send`); the working form is `claim data.config, :string, ...` (P51a).
1765
+ `find` on an unproduced claimed resource leaves the file absent — find never produces.
1766
+ - **P51 — working claim/produce pattern**: `module A; extend Resource; self.pkgdir='app';
1767
+ claim data.config, :string, "v1\n"; end` → `A.data.config.produce.find` exists, content
1768
+ `v1`; `exists?(produce: false) => false` vs default `exists? => true`.
1769
+ - **P52 — Persist basics**: first/second call caching, custom driver registration via
1770
+ `Persist::LOAD_DRIVERS`/`SAVE_DRIVERS` (not `save_drivers`/`load_drivers` readers),
1771
+ `:memory`, `update:`/`check:` handling, `File.delete` on a missing cache raising ENOENT.
1772
+
1773
+ ## P53 — CachingResults "Checking if cached" and directory defaults
1774
+
1775
+ Command: `ruby -Ilib tmp/probe53.rb` (fresh key `my_key_p53`, cache cleared first).
1776
+
1777
+ ```
1778
+ actual cache file => "var/cache/persistence/my_key_p53"
1779
+ File.exist? => false
1780
+ doc form persistence_path('my_key_p53', :ma...) => TypeError
1781
+ Persist.cache_dir => "var/cache/persistence" (class String)
1782
+ Persist.lock_dir => "/home/mvazque2/.scout/tmp/persist_locks" (class String, Path)
1783
+ :binary load => "\x00\x01ABC\n" raw bytes (6-byte file)
1784
+ lock_dir= accepts String => "/tmp/p53locks"
1785
+ ```
1786
+
1787
+ Interpretation: `Persist.persistence_path(name, :marshal)` (the doc's form) is invalid — the
1788
+ signature is options-hash only; cache file names never carry a type suffix;
1789
+ `Persist.cache_dir` is a plain relative String and `lock_dir` an absolute String under
1790
+ `$HOME/.scout/tmp/persist_locks` (nothing like `#<Path tmp/persist_locks>`).
1791
+
1792
+ ## P54 — Cookbook NamedArray / Log block / filename= / CaseInsensitiveHash
1793
+
1794
+ Command: `ruby -Ilib tmp/probe54.rb` (only `require 'scout-essentials'`).
1795
+
1796
+ ```
1797
+ SampleInfo recipe runs; output: S001..S003 (Human, Liver) # doc says (Human, Lua) for S003
1798
+ tmp/probe54.rb:26: uninitialized constant NamedArray (NameError)
1799
+ Log.debug block return => nil (duration appended by Log)
1800
+ ```
1801
+
1802
+ P54b–P54e (with explicit requires):
1803
+
1804
+ ```
1805
+ require 'scout/named_array' → record[:status] => "active"; to_hash => {:count=>42,...}
1806
+ require 'scout/indiferent_hash/case_insensitive'
1807
+ → params['format'] => "CSV"; params[:type] => "gene"
1808
+ CMD pipe stream filename= setter → "error_lines" (works)
1809
+ ```
1810
+
1811
+ Interpretation: `NamedArray` and `CaseInsensitiveHash` are NOT loaded by
1812
+ `require 'scout-essentials'` (see `lib/scout-essentials.rb` requires list); every doc example
1813
+ using them needs an explicit require. `stream.filename=` exists. The doc's sample output
1814
+ "(Human, Lua)" contradicts its own code.
1815
+
1816
+ ## P55–P56 — PathResolution.md: Path as String, path_maps, follow templates
1817
+
1818
+ Command: `ruby -Ilib tmp/probe55.rb`, `ruby -Ilib tmp/probe56.rb`.
1819
+
1820
+ ```
1821
+ Path.setup('data/config.yaml').class => String ; is_a?(Path) => true
1822
+ Path.path_maps[:user] => "{HOME}/.scout/{TOPLEVEL}/{SUBPATH}"
1823
+ add_path(:my_location, '/custom/{PATH}') then follow(:my_location) => "/custom/data/config.yaml"
1824
+ prepend_path(:user, '/shared/{PATH}') → map_order starts [:user, :current, :user, ...] (dup!)
1825
+ append_path(:global, '/opt/data/{PATH}') → global map replaced, appended at tail
1826
+ Path.follow('data/file', '/shared/{PATH/data/converted}') => "/shared/converted/file"
1827
+ ```
1828
+
1829
+ P56 (Resource-annotated path, pkgdir 'p56eapp'):
1830
+
1831
+ ```
1832
+ find (before any produce) => "/home/mvazque2/.p56eapp/data/x"
1833
+ pth.exists? default (produce: true) => true # file produced on the fly
1834
+ Open.exist?(pth) default => true
1835
+ ```
1836
+
1837
+ Interpretation: `prepend_path`/`append_path` do NOT reset the cached `@@map_order`
1838
+ (`add_path` does), so re-registering an existing name leaves a duplicate entry in the order.
1839
+ `exists?` defaults to producing.
1840
+
1841
+ ## P57–P58 — PersistenceAndResources.md: cache paths, drivers, lock API, claim dispatch
1842
+
1843
+ Command: `ruby -Ilib tmp/probe57.rb`, `ruby -Ilib tmp/probe58.rb`, `ruby -Ilib tmp/probe58b.rb`.
1844
+
1845
+ ```
1846
+ persistence_path('short_key') => "var/cache/persistence/short_key" # no type suffix, no digest
1847
+ long/unsafe key => digested name
1848
+ Persist.respond_to?(:lock) => false # no Persist.lock API
1849
+ persist arity-1 block receives target file; custom :string claim works
1850
+ :proc arity 1 receives the resolved final path; returning a String also writes it
1851
+ Resource singletons: :claim, :produce, :pkgdir=, :subdir=, :lock_dir=, :rake_dirs, ...
1852
+ standalone lib: :proc writing a TSV raises NameError: uninitialized constant Resource::TSV
1853
+ ```
1854
+
1855
+ Interpretation: the doc's `Persist.lock(file){}` API does not exist (locking is `Open.lock`);
1856
+ cache paths have no serialization-type component; the `:proc` dispatch in
1857
+ `Resource#produce` handles String/IO/StringIO/Array/TSV/TSV::Dumper only.
1858
+
1859
+ ## P59–P61 — map_order mutation, proc-nil latching
1860
+
1861
+ Command: `ruby -Ilib tmp/probe59.rb`, `ruby -Ilib tmp/probe61.rb`, `ruby -Ilib tmp/probe61b.rb`.
1862
+
1863
+ ```
1864
+ instance map_order= works on an annotated Path ([:current, :cache]) and does NOT touch Path.map_order
1865
+ Path-level: add_path resets @@map_order and appends the new map (zz inserted before :default)
1866
+ :proc returning nil (arity 0): file stays missing; next produce re-raises the latched exception
1867
+ → "Error producing a: uninitialized constant Resource::TSV"; @produced latches
1868
+ :proc arity 1 returning nil after writing the file itself: works (file exists, produce OK)
1869
+ ```
1870
+
1871
+ Interpretation: there is no class-level `Path.map_order=`; instance-level `map_order=`
1872
+ (annotation) exists. A nil-returning proc that did not write the file leaves the resource
1873
+ missing, and the failure is latched in `@produced` (produce re-raises instead of retrying).
1874
+
1875
+ ## P62–P63 — serialization driver matrix (CachingResults table)
1876
+
1877
+ Command: `ruby -Ilib tmp/probe62.rb`, `ruby -Ilib tmp/probe63.rb`.
1878
+
1879
+ ```
1880
+ :string => "hello" ; :text => "hello\n"
1881
+ :integer/:float roundtrip ; :boolean accepts TRUE_STRINGS (true/T/t/1/yes/y/on...)
1882
+ :marshal roundtrip ; :json roundtrip ; :binary raw bytes
1883
+ :yaml of a bare String then load → Psych AST nodes (Scalar/Document), not the String
1884
+ :yaml_array works (element-wise)
1885
+ No :array, :path, :file, :string_array drivers — Persist.load falls through to :serializer
1886
+ persist("k", :yaml, path: "cache/result.yaml") works; second call loads {:a=>1}
1887
+ ```
1888
+
1889
+ Interpretation: CachingResults' serialization table lists four types that do not exist
1890
+ (`:array`, `:path`, `:file`, `:string_array`) and omits `:serializer`/`:yaml_array`/TRUE_STRINGS.
1891
+
1892
+ ## P64–P65 — add/prepend/append and find fall-through vs extension alternatives
1893
+
1894
+ Command: `ruby -Ilib tmp/probe64.rb`, `ruby -Ilib tmp/probe65c.rb`.
1895
+
1896
+ ```
1897
+ add_path(:my_location, '/custom/data/{PATH}') → follow(:my_location) => "/custom/data/data/config.yaml"
1898
+ prepend_path(:user, '/shared/{PATH}') → order [:user, :current, :user, ...]
1899
+ append_path(:global, '/opt/data/{PATH}') → global map value replaced
1900
+ {PKGDIR}/{SUBPATH} custom map with pkgdir 'p64eapp' → "/shared/data/p64eapp/hg38.fa"
1901
+ located-but-missing path: find => "/nonexistent/absolute/path" (self, not nil)
1902
+ unlocated path: find => "/home/mvazque2/.scout/zz/no/such" (follow(:default) = user map, not nil)
1903
+ extension alternatives (symbol-keyed path_maps {current: dir, local: dir}):
1904
+ order [:current, :local] → plain file wins
1905
+ order [:local, :current] → .gz file wins
1906
+ ```
1907
+
1908
+ Interpretation: `find` NEVER returns nil — unlocated paths fall through to the `:user`
1909
+ (`:default`) map expansion and located-but-missing paths return themselves; compressed
1910
+ variants win purely by map order, confirming the "not necessarily uncompressed" pitfall.
1911
+
1912
+ ## P66 — lock_dir / cache_dir types; TmpFile flattening; Open.read(Path)
1913
+
1914
+ Command: `ruby -Ilib tmp/probe66.rb`.
1915
+
1916
+ ```
1917
+ Persist.cache_dir.class => String (default "var/cache/persistence")
1918
+ Persist.lock_dir => "/home/mvazque2/.scout/tmp/persist_locks" (String)
1919
+ TmpFile.tmp_for_file('some/file/name.txt') => ".../tmpfiles/some·file·name.txt" (separator flattened to '·')
1920
+ Open.read(Path.setup('VERSION')) => "1.8.8" (Path resolved via find)
1921
+ ```
1922
+
1923
+ Interpretation: neither cache_dir nor lock_dir is a Path object; TmpFile does NOT use
1924
+ path-map templates — it flattens separators into a single tmpfiles directory, contradicting
1925
+ PathResolution.md's "TmpFile.tmp_for_file uses Path patterns".