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,774 @@
1
+ # Implementation inventory — chunk 3: Path / Persist / Resource / Annotation
2
+
3
+ Phase 1 forensic documentation audit of `/bulk/mvazque2/git/scout-essentials`.
4
+ Source under `lib/scout/` is authoritative; every claim below carries a `file:line`
5
+ anchor and, where behaviour is non-obvious, references a probe from
6
+ `research/behavior-probes.md` (P34–P44, scripts under `tmp/probe3*.rb`/`tmp/probe4*.rb`).
7
+
8
+ Scope (22 files):
9
+ `lib/scout/path.rb`, `path/digest.rb`, `path/find.rb`, `path/tmpfile.rb`, `path/util.rb`;
10
+ `lib/scout/persist.rb`, `persist/open.rb`, `persist/path.rb`, `persist/serialize.rb`;
11
+ `lib/scout/resource.rb`, `resource/open.rb`, `resource/path.rb`, `resource/produce.rb`,
12
+ `resource/produce/rake.rb`, `resource/scout.rb`, `resource/software.rb`, `resource/sync.rb`,
13
+ `resource/util.rb`; `lib/scout/annotation.rb`, `annotation/annotated_object.rb`,
14
+ `annotation/annotation_module.rb`, `annotation/array.rb`.
15
+
16
+ ---
17
+
18
+ ## 1. Path subsystem
19
+
20
+ ### 1.1 `lib/scout/path.rb` — the Path model itself
21
+
22
+ `Path` is a **module**, not a class, and it *is itself an annotated type*:
23
+ - `module Path; extend Annotation; annotation :pkgdir, :libdir, :path_maps, :map_order, :where, :original` (path.rb:6-8).
24
+ So `Path.setup(str)` produces a String extended with `Path` whose annotation set is
25
+ those six keys, and `annotation_types` of such a Path is `[Path]` (verified P36f).
26
+
27
+ Instance methods:
28
+ - `Path.default_pkgdir` → `'scout'` (path.rb:10-12); `Path.default_pkgdir=` writer (path.rb:14-16).
29
+ - `#pkgdir` → `@pkgdir ||= Path.default_pkgdir` (path.rb:18-20).
30
+ - `#libdir` → `@libdir || Path.caller_lib_dir` (path.rb:22-24) — falls back to a **caller-stack**
31
+ scan (`find.rb:24-49`) that walks up from the calling file looking for a dir containing
32
+ any of `lib`, `bin`, `README.md` (find.rb:24, default `relative_to = ['lib','bin','README.md']`).
33
+ - `#path_maps` → `@path_maps ||= Path.path_maps.dup` (path.rb:26-28) — per-instance copy;
34
+ mutations on an instance do not leak to the global table.
35
+ - `#join(subpath = nil, prevpath = nil)` (path.rb:30-40): `Symbol` args are stringified,
36
+ `prevpath` is prepended, empty receiver yields the subpath itself; result is re-annotated
37
+ with `self.annotate(new)` (path.rb:38). Aliases `[]` and `/` (path.rb:42-43).
38
+ - `#method_missing(name, prev = nil, *args, &block)` (path.rb:45-51): if a block is given
39
+ or the name starts with `to_` it defers to `super`; **otherwise** it builds a path
40
+ segment — i.e. `Scout.share.data` ≡ `Scout.share.join(:data)`. This is the
41
+ `Scout.etc["path_maps"]` / `Scout.share.software` mechanism.
42
+ - `Path` instances also carry `#where` (find.rb:211-213) and `#original`
43
+ (find.rb:215-217) readers, written by `#annotate_found_where` (find.rb:204-209) —
44
+ the map name a `find` succeeded under, and a self-annotated copy of the *unlocated*
45
+ original path.
46
+
47
+ Requires: `annotation`, `path/util`, `path/tmpfile`, `path/digest` (path.rb:1-4) and,
48
+ at the bottom, `path/find` (path.rb:53) — `find` is deliberately loaded after the
49
+ Path module body so `Path.extend Annotation` is in place first.
50
+
51
+ ### 1.2 `lib/scout/path/find.rb` — resolution algorithm
52
+
53
+ Global (module-function) state, all memoised in class variables:
54
+ - `Path.path_maps` (find.rb:85-101): `IndiferentHash` of 13 default maps:
55
+ `:current "{PWD}/{TOPLEVEL}/{SUBPATH}"`, `:home "{HOME}/{TOPLEVEL}/{PKGDIR}/{SUBPATH}"`,
56
+ `:user "{HOME}/.{PKGDIR}/{TOPLEVEL}/{SUBPATH}"`, `:global`, `:usr`, `:local`, `:fast`,
57
+ `:cache`, `:bulk`, `:lib "{LIBDIR}/{TOPLEVEL}/{SUBPATH}"`,
58
+ `:scout_essentials_lib "<libdir>/{TOPLEVEL}/{SUBPATH}"` (computed from `caller_lib_dir(__FILE__)`),
59
+ `:tmp "/tmp/{PKGDIR}/{TOPLEVEL}/{SUBPATH}"`, and `:default => :user` — a *symbol alias*.
60
+ - `Path.basic_map_order` (find.rb:103-105): `%w(current workflow user home local global usr lib fast cache bulk)`.
61
+ Note `workflow` is listed but has no default map, so it is silently dropped.
62
+ - `Path.map_order` (find.rb:107-119): derived order = basic order with the literal `lib`
63
+ slot replaced by every `*_lib` map followed by `lib`, then `(basic & all) + (all - basic)`.
64
+ Observed default (P34/P39):
65
+ `[:current, :user, :home, :local, :global, :usr, :scout_essentials_lib, :lib, :fast, :cache, :bulk, :default, :tmp]`
66
+ — `:default` and `:tmp` come last because they are not in the basic list.
67
+ - Instance-level `#map_order` (find.rb:136-144) re-derives from the instance `path_maps`,
68
+ keeping the global order as a template. There is **no `Path.map_order=` setter** —
69
+ the only supported mutation entry points are `Path.add_path` / `prepend_path` / `append_path`
70
+ (find.rb:121-134, module-function) and the instance counterparts (find.rb:146-159).
71
+ `Path.map_order=` raises `NoMethodError` (verified P39).
72
+ `Path.add_path` resets `@@map_order = nil` (find.rb:123) so the order is recomputed and
73
+ the new map lands at the end; `prepend_path`/`append_path` unshift/push onto the
74
+ *already computed* order (find.rb:128, 133), so they do not reset it.
75
+ - `Path.load_path_maps(filename)` (find.rb:161-176): if the file exists, `YAML.load`s a
76
+ `{where: location}` mapping and `add_path`es each entry; failures are only logged
77
+ (`Log.error`, find.rb:171). Used at boot by `resource/scout.rb:9`.
78
+
79
+ Substitution engine — `Path.follow(path, map, map_name = nil)` (find.rb:51-83):
80
+ - a map without `{` gets `/{PATH}` appended (find.rb:52);
81
+ - `{PKGDIR}` uses the path's (or module-level default) pkgdir, unwrapping nested
82
+ pkgdir-reponding objects (find.rb:53-57);
83
+ - substitution order: `{PKGDIR}`, `{HOME}`, `{RESOURCE}` (also `path.pkgdir.to_s`),
84
+ `{PWD}` (via `FileUtils.pwd`), `{TOPLEVEL}` (`_toplevel`), `{SUBPATH}` (`_subpath`),
85
+ `{BASENAME}`, `{PATH}`, `{LIBDIR}`, `{MAPNAME}`, `{REMOVE}/`, `{REMOVE}` (find.rb:58-69);
86
+ - trailing `/` is dropped when the path has no subpath (find.rb:71);
87
+ - then a loop rewrites nested `{KEY/ORIG/REPLACE}` forms by recursive `follow`
88
+ (find.rb:73-80).
89
+
90
+ Decomposition helpers (find.rb:178-188): `#_parts` (`split("/")` memoised),
91
+ `#_subpath` (all but first part, or `nil`), `#_toplevel` (first part). For a single
92
+ segment path `_toplevel == self` and `_subpath == nil` (see test `test_find.rb`).
93
+
94
+ `Path.located?(path)` (find.rb:193-198): true iff the string starts with `/`, `~/`, or `./`
95
+ (byte comparisons `SLASH = "/"[0]`, `HOME = "~"[0]`, `DOT = "."[0]`, find.rb:190-192).
96
+ Instance `#located?` delegates (find.rb:200-202).
97
+
98
+ `Path.exists_file_or_alternatives(file)` (find.rb:237-245): returns the file itself if
99
+ `Open.exist?` or `Open.directory?`; otherwise tries `file + ".gz" / ".bgz" / ".zip"`
100
+ in that fixed order; otherwise `nil`.
101
+
102
+ **`Path#find(where = nil)`** (find.rb:247-274) — the core resolution:
103
+ 1. If `located?`: absolute path exists → return `self.annotate(File.expand_path(self))`;
104
+ else check alternatives (`Path.exists_file_or_alternatives`) and annotate+return the
105
+ alternative if present; **else return `self` unchanged** (find.rb:248-259). It never
106
+ returns `nil` for located paths (verified P34: missing located path returns itself).
107
+ 2. `where == 'all' || :all` → `find_all` (find.rb:261).
108
+ 3. `where` given (Symbol/String) → `follow(where)` (find.rb:263). If the name is a String
109
+ not in `path_maps`, `follow` synthesises `File.join(map_name, '{TOPLEVEL}/{SUBPATH}')`
110
+ (find.rb:222-224); an unknown Symbol still raises `"Map not found"` (find.rb:225).
111
+ 4. Otherwise iterate `map_order`, skipping names not present in `path_maps`
112
+ (find.rb:265-271): `follow(map_name, false)` (no annotation), then
113
+ `Path.exists_file_or_alternatives`; first hit is returned via
114
+ `annotate_found_where(found, map_name)` which sets `@where` and `@original`
115
+ (find.rb:269-270). The alternative check considers gz/bgz/zip suffixes and, when only
116
+ a compressed variant is on disk, find returns the suffixed path annotated with the
117
+ winning map name (verified P44).
118
+ 5. Nothing found → **fall through to `follow(:default)`** (find.rb:273), i.e. by default
119
+ the `:user` map location (`{HOME}/.{PKGDIR}/{TOPLEVEL}/{SUBPATH}`), *not* nil
120
+ (verified P34: `/tmp/nonexistent…` returned self; unlocated `share/data/some_file`
121
+ returned `~/.scout/share/…`).
122
+
123
+ Related predicates:
124
+ - `#exist?` / `#exists?` (find.rb:276-283): `File.exist?(self.find) || File.directory?(…)`.
125
+ - `#find_all(caller_lib = nil, search_paths = nil)` (find.rb:284-288): maps `map_order`
126
+ through `find`, selects existing, `uniq`. (The `caller_lib`/`search_paths` params are
127
+ accepted but unused.)
128
+ - `#find_with_extension(extension, *args, produce: true)` (find.rb:290-303): plain find
129
+ first; if that exists and is not a directory it wins; otherwise try
130
+ `set_extension(ext).find` for each extension (Array or single) and return the first
131
+ that exists; **fall back to the un-extended `found`** (find.rb:302).
132
+
133
+ ### 1.3 `lib/scout/path/util.rb` — filesystem helpers on Path
134
+
135
+ - `Path.is_filename?(string, need_to_exists = true)` (util.rb:8-16): `Path` passes; a
136
+ String passes if it has no newline, is < 265 chars (or no component > 265), and — with
137
+ `need_to_exists` — `File.exist?`.
138
+ - `Path.can_read?` / `can_write?` (util.rb:18-33): remote URLs are always "readable" but
139
+ never "writable"; otherwise `is_filename?` + not-a-directory checks, `find` applied for
140
+ Path instances, and for `can_write?` writability of the file or of its parent dir.
141
+ - `Path.sanitize_filename(filename, length = 254)` (util.rb:35-50): overlong names are
142
+ truncated and suffixed `--<len>--<digest5>` plus a preserved 2–9 char extension.
143
+ - `#directory?` → `nil` unless `exist?`, else `File.directory?(self.find)` (util.rb:52-55).
144
+ - `#realpath`, `#relative_to(dir)` (util.rb:57-63) — both operate on `find`.
145
+ - `#sub`, `#dirname`, `#basename` (util.rb:65-75) re-annotate the result.
146
+ - `#glob(pattern = "*")` (util.rb:77-97): a receiver containing `*` is globbed directly
147
+ when located, else via `glob_all`; otherwise the receiver must `exist?`, results are
148
+ annotated and, if the found location has an `original`, each result's `original` is
149
+ rewritten relative to it (util.rb:91-93).
150
+ - `#glob_names(...)` (util.rb:99-101) = `glob(...).collect(&:basename)`.
151
+ - `#glob_all(pattern = nil, caller_lib = nil, search_paths = nil)` (util.rb:103-122):
152
+ iterates every entry of `self.path_maps` (or `Path.path_maps`), calls `find(where)` per
153
+ entry, keeps only located results, globs each, annotates, and sets `original`/`where`
154
+ when a pattern was given.
155
+ - Extension helpers: `#get_extension(multiple = false)` (util.rb:124-133; when `multiple`
156
+ collects up to 4 extra dotted segments), `#set_extension(ext)` (util.rb:135-137, appends
157
+ `.ext`), `#unset_extension` (util.rb:139-146), `#remove_extension(ext = nil)`
158
+ (util.rb:148-154), `#replace_extension(new_ext, multiple = false)` (util.rb:156-166).
159
+ - Freshness: `#newer_files(*files)` (util.rb:168-172) and `#outdated?(...)` (util.rb:174-176)
160
+ select files newer than self; `Path.newer?(path, file, by_link = false)`
161
+ (util.rb:179-194) returns a *numeric age difference* (negative) when the file is newer
162
+ than the path, `false` otherwise, `true` when `file` does not exist — it is truthy/falsy
163
+ tri-state, not a boolean. Uses `Open.mtime` (so `<file>.info` Step blobs participate,
164
+ see chunk-2 findings) unless `by_link` (then `File.lstat.mtime`).
165
+ - `#final_pkgdir` (util.rb:196-200): unwraps pkgdir chains (`pkgdir.pkgdir while …`).
166
+
167
+ ### 1.4 `lib/scout/path/digest.rb` — `#digest_str`
168
+
169
+ `Path#digest_str` (digest.rb:3-22):
170
+ - directory → `glob("*")`, reject directories, `Annotation.purge` the annotated results,
171
+ and emit `"Directory MD5: <n> <digest>"` when > 10 files else `"Directory MD5: <digest>"`;
172
+ - existing located file → `"File MD5: <Misc.digest_file(self)>"`;
173
+ - otherwise `'\'' + self << '\''` (note: literal quote wrapping, digest.rb:20 — this is a
174
+ quoting quirk, not an error).
175
+
176
+ ### 1.5 `lib/scout/path/tmpfile.rb`
177
+
178
+ `TmpFile.with_path(*args, &block)` (tmpfile.rb:2-7) wraps `TmpFile.with_file` and
179
+ `Path.setup`s the generated temp name before yielding. This is the only content; the
180
+ tmp-root/digest conventions live in `lib/scout/tmpfile.rb` (chunk-1 inventory).
181
+
182
+ ---
183
+
184
+ ## 2. Persist subsystem
185
+
186
+ ### 2.1 `lib/scout/persist.rb` — cache/lock roots, `persistence_path`, `persist`
187
+
188
+ Class-level configuration:
189
+ - `Persist.cache_dir` defaults to `Path.setup("var/cache/persistence")` (persist.rb:12-14)
190
+ — a **relative** Path; `cache_dir=` accepts a String or Path (persist.rb:8-10).
191
+ - `Persist.lock_dir` defaults to `Path.setup("tmp/persist_locks").find` (persist.rb:16-19),
192
+ which resolves through the default path maps to `$HOME/.scout/tmp/persist_locks`
193
+ (observed P41: `/home/mvazque2/.scout/tmp/persist_locks`).
194
+
195
+ **`Persist.persistence_path(name, options = {})`** (persist.rb:22-28):
196
+ - `options` is an options hash; the defaults add `:dir => Persist.cache_dir`;
197
+ "other" options are pulled out and appended by `TmpFile.tmp_for_file` as
198
+ `:<digest>` (observed P41: `…/foo:41a66144f5d092dbc008b979700699d4`).
199
+ - `Persist.persistence_path(name, :marshal)` is **not valid** — the symbol is treated as
200
+ the options hash and fails with `TypeError: can't define singleton` (P41). The type is
201
+ *never* passed to `persistence_path`; `Persist.persist` takes it as a separate
202
+ positional parameter.
203
+ - A `:key` option yields `name + "[key]"` via TmpFile's clean-options naming
204
+ (observed P41: `…/foo[K]`), following the same `MAX_FILE_LENGTH`/digest truncation rules
205
+ as all TmpFile names (`tmpfile.rb`, chunk-1).
206
+
207
+ `Persist.persist(name, type = :serializer, options = {}, &block)` (persist.rb:32-144):
208
+ 1. pulls `:persist`-prefixed sub-options; `:persist => false` short-circuits to `yield`
209
+ (persist.rb:33-34).
210
+ 2. `file = persist_options[:path] || options[:path] || persistence_path(name, options)`
211
+ (persist.rb:36); `:data`, `:check`, `:no_load`, `:update` read from either level
212
+ (persist.rb:37-41). `update` may be a Time, a Numeric (seconds of staleness) or truthy
213
+ (persist.rb:45-49); `Path`-valued `update` is converted with `Open.mtime` (persist.rb:42).
214
+ If `file` is a Path and `check` is set, `update` is forced when the file is outdated
215
+ (persist.rb:44).
216
+ 3. `type == :memory` bypasses all locking and files: repo is `options[:memory] ||
217
+ options[:repo] || MEMORY_CACHE`, keyed by `file`; `update` recomputes, otherwise the
218
+ cached value is reused (persist.rb:51-59). `Persist.memory(name, options, &block)`
219
+ (persist.rb:146-149) is a thin wrapper that derives a `:path`/`:persist_path` from
220
+ `[name, options[:key]] * ":"` when a `:key` is given.
221
+ 4. Otherwise a lockfile is built as `persistence_path(file + '.persist',
222
+ :dir => Persist.lock_dir)` (persist.rb:61) and the whole read-or-compute critical
223
+ section runs inside `Open.lock` (persist.rb:63), i.e. the chunk-2 `Lockfile` payload
224
+ mechanism with `LockInterrupted` semantics.
225
+ 5. Cache hit (`Open.exist?(file) && ! update`): with `:no_load => true` return the file
226
+ path, else `Persist.load(file, type)` (persist.rb:64-69).
227
+ 6. Miss: `Open.rm(file.find)` if updating (persist.rb:72); `file = file.find` when it is a
228
+ Path (persist.rb:74). A block with `arity == 1` is called with either the caller's
229
+ `:data` or the file name (persist.rb:75-81); arity 0 blocks are plain `yield`
230
+ (persist.rb:83).
231
+ 7. `res.nil?` handling (persist.rb:86-99): with `no_load` return the file; else with a nil
232
+ `type` return nil; else `Persist.load(file, type)`.
233
+ 8. Stream results (`IO`/`StringIO`): tee into `tee_copies + 1` streams (default 1 copy,
234
+ persist.rb:102), mark `main.lock = lock`, spawn a saver thread running
235
+ `Open.sensible_write(file, main)` (persist.rb:103-110), wrap every copy as a
236
+ `ConcurrentStream` with `:threads => t, :filename => file, :autojoin => true, :next => …`
237
+ (persist.rb:111-114) and return the first copy, raising `KeepLocked.new(res)` so the
238
+ outer `Open.lock` keeps the lockfile until the stream joins (persist.rb:116; the
239
+ `KeepLocked < DontPersist` taxonomy is established in chunk 1/2).
240
+ 9. Non-stream results are written via `Persist.save(res, file, type)` and `res` is
241
+ replaced by the save result unless it is nil (persist.rb:118-119) — for file-backed
242
+ types `Persist.save` returns nil, so the *block's* value is returned, not the file path.
243
+ 10. Error path (persist.rb:121-135): on any `Exception`, the partially written file is
244
+ removed under `Thread.handle_interrupt(Exception => :never)` unless the exception is a
245
+ `DontPersist` (persist.rb:130), and the exception is re-raised unless
246
+ `options[:canfail]` (persist.rb:133) — with `:canfail` execution falls through to
247
+ persist.rb:137-141 returning `file` when `no_load` is exactly `true`, else `res`
248
+ (which may be nil).
249
+ 11. Final return for a successful write: `file` if `no_load == true` else `res`
250
+ (persist.rb:137-141).
251
+
252
+ ### 2.2 `lib/scout/persist/serialize.rb` — types, drivers, save/load
253
+
254
+ Constants: `TRUE_STRINGS` (Set of 13 truthy spellings, serialize.rb:6) and
255
+ `SERIALIZER = :json` (serialize.rb:7) — **the default serializer type is JSON, not marshal**.
256
+
257
+ Pluggable drivers: `save_drivers` / `load_drivers` hashes with accessors
258
+ (serialize.rb:9-17); `save` consults `save_drivers[type]` first, calling it with
259
+ `(content)` when its arity is 1 (result written with `Open.sensible_write`) or
260
+ `(file, content)` otherwise (serialize.rb:103-109); `load` consults `load_drivers[type]`
261
+ (serialize.rb:133-135).
262
+
263
+ `Persist.serialize(content, type)` (serialize.rb:19-47):
264
+ - `nil, :string, :text, :integer, :float, :boolean, :file, :path, :select, :folder,
265
+ :binary` → `content.read` for IO/StringIO else `content.to_s` (serialize.rb:23-28);
266
+ - `:array` → `content * "\n"` (serialize.rb:29-30);
267
+ - `:yaml` / `:json` / `:marshal` → `to_yaml` / `to_json` / `Marshal.dump` (serialize.rb:31-36);
268
+ - `:annotation, :annotations` → `Annotation.tsv(content, :all).to_s` (serialize.rb:37-38) —
269
+ **requires an external `Annotation.tsv` that this gem does not define** (there is no
270
+ `Annotation#tsv` anywhere under `lib/`; `grep` for `def tsv` in `annotation*.rb` is
271
+ empty) and therefore raises `NoMethodError: undefined method 'tsv' for module Annotation`
272
+ at runtime (verified P42/P38);
273
+ - `:serializer` is rewritten to `SERIALIZER` (:json) first (serialize.rb:21);
274
+ - any `*_array` suffix is expanded recursively and joined with newlines (serialize.rb:40-43);
275
+ - anything else raises `"Persist does not know …"` (serialize.rb:44).
276
+
277
+ `Persist.deserialize(serialized, type)` (serialize.rb:49-85):
278
+ - `nil, :text, :stream` → as-is (serialize.rb:54-55); `:string, :file, :select, :folder` →
279
+ `strip` (serialize.rb:56-57); `:path` → `Path.setup(serialized.strip)` (serialize.rb:58-59);
280
+ - `:integer`/`:float` → `to_i`/`to_f`; `:boolean` → membership in `TRUE_STRINGS`
281
+ (serialize.rb:60-65);
282
+ - `:array` → `split("\n")` (serialize.rb:66-67);
283
+ - `:yaml` → **`YAML.parse`** (serialize.rb:68-69), i.e. a Psych AST node, *not* a loaded
284
+ Ruby object — a documented-in-code asymmetry with `Persist.load(file, :yaml)` which uses
285
+ `Open.yaml` (serialize.rb:141). Verified P35: deserializing a YAML string returns a
286
+ `Psych::Nodes::Document`.
287
+ - `:json` → `JSON.parse`; `:marshal` → `Marshal.load` (serialize.rb:70-73);
288
+ - `:annotation, :annotations` → `Annotation.load_tsv(TSV.open(serialized))`
289
+ (serialize.rb:74-75) — depends on **both** an external `TSV` class and an external
290
+ `Annotation.load_tsv`; neither is defined in this gem (`grep TSV lib/` only matches the
291
+ two call sites plus Log/progress references). So the `:annotation` persistence type is
292
+ *declared but not functional* standalone (verified P38: `Persist.persist(…, :annotation, …)`
293
+ raises `NoMethodError`).
294
+ - `*_array` recursion as in serialize (serialize.rb:77-80); unknown raises
295
+ (serialize.rb:82).
296
+
297
+ `Persist.save(content, file, type = :serializer)` (serialize.rb:88-122):
298
+ - normalises `nil` type to `:serializer`; `:memory` swaps in the shared `MEMORY` hash;
299
+ returns immediately on `nil` content (serialize.rb:89-95) — note `type == :memory` is
300
+ checked twice (serialize.rb:92 and :94, dead line);
301
+ - `Hash === type` writes `type[file] = content` (in-memory repo) and returns (serialize.rb:97-100);
302
+ - driver hook (serialize.rb:103-109);
303
+ - `:binary` forces `ASCII-8BIT` and writes with `f.puts` in `wb` mode, returning
304
+ `content` (serialize.rb:111-116);
305
+ - everything else: `serialize(content, type)` + `Open.sensible_write(file, serialized,
306
+ :force => true)` and **returns nil** (serialize.rb:117-121) — which is why
307
+ `Persist.persist` keeps the block value for file-backed types (persist.rb:118-119).
308
+
309
+ `Persist.load(file, type = :serializer)` (serialize.rb:124-169):
310
+ - `file = file.find if Path === file` (serialize.rb:125); returns `nil` unless the type is
311
+ a Hash (memory repo) or the file exists (serialize.rb:130);
312
+ - special-cased loaders: `:binary` (`Open.read … :mode => 'rb'`), `:yaml` (`Open.yaml`, i.e.
313
+ `YAML.unsafe_load`), `:json` (`Open.json`), `:marshal`/`:serializer` (`Open.marshal`),
314
+ `:stream` (`Open.open(file)`), `:path` (return the Path itself), `:file` (read the file,
315
+ rewrite a leading `./` to the file's dirname, and return that value only if it
316
+ `Path.is_filename?`, else return `file`), `:file_array` (same per line)
317
+ (serialize.rb:137-162);
318
+ - `Hash` type → repo lookup (serialize.rb:163-164);
319
+ - otherwise generic `Open.read` + `deserialize` (serialize.rb:165-168). Note `:file_array`
320
+ exists as a *load-only* special case; its serialization goes through the generic
321
+ `*_array` machinery (`:file` + "\n").
322
+
323
+ ### 2.3 `lib/scout/persist/open.rb` — typed readers
324
+
325
+ `Open.json(file)` (open.rb:6-9) and `Open.yaml(file)` (open.rb:11-14) both call
326
+ `file.find_with_extension(:json|:yaml)` when given a Path — so they transparently fall
327
+ back to `.json.gz`-style alternatives per `Path#find_with_extension`.
328
+ `Open.yaml` uses **`YAML.unsafe_load`** (open.rb:13). `Open.marshal(file)` (open.rb:16-18)
329
+ has no extension fallback.
330
+
331
+ ### 2.4 `lib/scout/persist/path.rb` — instance sugar
332
+
333
+ Reopens `Path` to add `#yaml`, `#json`, `#marshal` delegating to the corresponding
334
+ `Open.*` readers (path.rb:2-14). Loaded by `persist.rb:3` only, so these methods exist
335
+ once `scout/persist` has been required.
336
+
337
+ ---
338
+
339
+ ## 3. Resource subsystem
340
+
341
+ ### 3.1 `lib/scout/resource.rb` — the Resource module itself
342
+
343
+ `module Resource; extend Annotation; annotation :pkgdir, :libdir, :subdir, :resources,
344
+ :rake_dirs, :path_maps, :map_order, :lock_dir` (resource.rb:19-21). Like `Path`, `Resource`
345
+ is an *annotated module*: a resource is any module that `extend`s `Resource` and thereby
346
+ gains `claim`, `produce`, `identify`, … plus per-instance annotation ivars.
347
+
348
+ The file `require_relative`s its parts **twice** (resource.rb:3-8 and :10-17) — harmless
349
+ (`require_relative` is idempotent) but noteworthy.
350
+
351
+ Class-level: `Resource.default_resource` accessor (resource.rb:23-29). `Resource.default_lock_dir`
352
+ = `Path.setup('tmp/produce_locks').find` (resource.rb:31-33) → resolves to
353
+ `$HOME/.scout/tmp/produce_locks` (observed P43).
354
+
355
+ Instance (i.e. per-resource) helpers:
356
+ - `#path_maps` / `#map_order` duplicate the Path globals (resource.rb:35-41) so a resource
357
+ can be scoped independently;
358
+ - `#prepend_path(name, map)` / `#append_path(name, map)` (resource.rb:43-51) mutate that copy;
359
+ - `#subdir` defaults to `""` (resource.rb:53-55); `#lock_dir` defaults to
360
+ `Resource.default_lock_dir` (resource.rb:57-59); `#pkgdir` defaults to
361
+ `Path.default_pkgdir` (resource.rb:61-63);
362
+ - `#root` → `Path.setup(subdir.dup, self, self.libdir, @path_maps, @map_order)`
363
+ (resource.rb:65-67) — the resource acts as the *pkgdir annotation* of its root Path,
364
+ which is how `Path#produce` finds its way back to the claiming resource
365
+ (`resource/path.rb:6`);
366
+ - `#method_missing(name, prev = nil, *args)` (resource.rb:69-75) forwards to `root`,
367
+ reproducing the Path segment-building ergonomics at resource level (`Scout.etc`,
368
+ `Scout.share`, `Scout.lib`, …).
369
+
370
+ ### 3.2 `lib/scout/resource/scout.rb` — bootstrapping `Scout` as the default resource
371
+
372
+ - `module Scout; extend Resource; self.pkgdir = 'scout'` (scout.rb:1-5) — the top-level
373
+ `Scout` namespace *is* a Resource with pkgdir `'scout'` (and `Path.default_pkgdir`
374
+ is also `'scout'`, path.rb:11).
375
+ - `Resource.default_resource = Scout` (scout.rb:7).
376
+ - `Path.load_path_maps(Scout.etc["path_maps"])` (scout.rb:9) — at require time the
377
+ `etc/path_maps` YAML (if it exists) augments the built-in map table; failures are logged
378
+ and swallowed (find.rb:170-172).
379
+
380
+ ### 3.3 `lib/scout/resource/path.rb` — Path#produce and friends
381
+
382
+ `Path#produce(force = false)` (path.rb:2-21):
383
+ - re-raises a previously stored `@produced` if it is an Exception (path.rb:3) — failures
384
+ are memoised and re-raised on every subsequent call;
385
+ - returns `self` when not forcing and either the located file exists or a previous
386
+ produce succeeded (path.rb:4);
387
+ - dispatches to `self.pkgdir.produce(self, force)` when the pkgdir annotation is a
388
+ Resource (path.rb:6-7), otherwise returns `false` (path.rb:8-10) — a Path with no
389
+ Resource pkgdir **silently returns false**;
390
+ - `ResourceNotFound` is caught and turned into `@produced = false` (path.rb:11-12), so a
391
+ missing claim makes `produce` return `false` rather than raise;
392
+ - any other exception is logged with `Log.warn` and re-raised (path.rb:13-17);
393
+ - `ensure` sets `@produced = true` if still nil (path.rb:18-20) — so a *successful*
394
+ no-op (nothing to do) is memoised as `true`, a `ResourceNotFound` leaves `false`.
395
+ Consequence (verified P37c): requesting `.gz` when only the un-compressed name is
396
+ claimed makes `produce` return `false` the first time (extension fall-through found
397
+ the `.gz` claim to be absent) yet `find` still returns the `.gz` name that was created
398
+ by the claim registered for it; conversely requesting `h` when `h.gz` is claimed
399
+ produces the `.gz` file and `find` returns the `.gz` path.
400
+
401
+ `Path#produce_with_extension(extension, *args)` (path.rb:23-34): try `produce`, on
402
+ exception try `set_extension(extension).produce`, re-raise the *first* exception if the
403
+ second also fails.
404
+
405
+ `Path#produce_and_find(extension = nil, *args)` (path.rb:36-47): `find_with_extension` /
406
+ `find`; if the result exists return it, otherwise produce (with extension) and raise
407
+ `"Not found: #{self}"` unless something truthy came back.
408
+
409
+ `Path#relocate` (path.rb:49-52): `self` if it already exists, else `Resource.relocate(self)`.
410
+ `Path#identify` (path.rb:54-56) delegates to `Resource.identify`.
411
+
412
+ Convenience IO: `#open(*args, &block)` and `#read` call `produce` first then `Open.open`/
413
+ `Open.read` (path.rb:58-66); `#write(*args, &block)` writes to `self.find` **without**
414
+ producing (path.rb:68-70); `#list` = `produce_and_find('list')` + `Open.list` (path.rb:72-75).
415
+
416
+ `Path#exists?(produce: true)` (path.rb:77-85) — redefined here on top of the base
417
+ `Path#exist?` (find.rb:276) to optionally *produce* the path before concluding.
418
+
419
+ `Path#find_with_extension(extension, *args, produce: true)` (path.rb:87-100) — redefined
420
+ here so each candidate is checked with `exists?(produce: produce)`; the base version in
421
+ `path/find.rb:290` is shadowed once `resource/path` is loaded.
422
+
423
+ ### 3.4 `lib/scout/resource/produce.rb` — claims and the produce engine
424
+
425
+ `Resource#claim(path, type, content = nil, &block)` (produce.rb:6-14):
426
+ - `type == :rake` registers in `@rake_dirs[path] = content || block`;
427
+ - everything else registers `@resources[path] = [type, content || block]`.
428
+ There is **no validation of `type`** at claim time; the registry is keyed by the raw
429
+ path string.
430
+
431
+ Lookup helpers: `#rake_for(path)` (produce.rb:16-23) selects rake dirs that are prefixes
432
+ of `path` (`Misc.path_relative_to`) sorted by length, taking the longest;
433
+ `#has_rake?(path)` (produce.rb:25-27).
434
+
435
+ `#run_rake(path, rakefile, rake_dir)` (produce.rb:29-51): computes the task as the path
436
+ relative to the rake dir; `produce`s/`find`s the rakefile if it responds; resolves
437
+ `rake_dir.find(:user)`; sets `Thread.current["resource"] = self`; calls `ScoutRake.run`
438
+ (with the block when the rakefile is a Proc); on `ScoutRake::TaskNotFound` it walks *up*
439
+ one directory level (`task = File.join(File.basename(rake_dir), task)`,
440
+ `rake_dir = File.dirname(rake_dir)`) and retries, re-raising at the filesystem root.
441
+
442
+ **`Resource#produce(path, force = false)`** (produce.rb:53-158) — the engine:
443
+ 1. Claim selection (produce.rb:54-77): exact match in `@resources`; else match on
444
+ `path.original` (the unlocated original when a *found* path was passed in); else a
445
+ rake claim; else — for paths not ending in `.gz`/`.bgz` — **automatic extension
446
+ fall-through**: retry with `path + '.gz'` and then `path + '.bgz'`, and only then raise
447
+ `ResourceNotFound "Resource is missing and does not seem to be claimed: …"`
448
+ (produce.rb:64-76). Paths that already end in `.gz`/`.bgz` raise `ResourceNotFound`
449
+ directly (produce.rb:74-76).
450
+ 2. Target resolution: `path.find(:default)` when forcing, `path.find` otherwise
451
+ (produce.rb:79-83).
452
+ 3. Production runs only when a type exists and the target is missing or forcing
453
+ (produce.rb:85). Locking: `lock_filename = TmpFile.tmp_for_file(final_path,
454
+ :dir => lock_dir)` then `Open.lock lock_filename { … }` (produce.rb:87-89) — a
455
+ per-resource lock dir (default `$HOME/.scout/tmp/produce_locks`) and a lock *name
456
+ derived from the final path*, following TmpFile digest naming. `force` removes the
457
+ existing target before the inner check (produce.rb:90).
458
+ 4. Dispatch on `type` (produce.rb:94-144), each arm wrapped in a rescue that removes the
459
+ partial target and re-raises (produce.rb:145-148):
460
+ - `:string` → `Open.sensible_write(final_path, content)` (produce.rb:96-97);
461
+ - `:csv` → **raises `"TSV/CSV Not implemented yet"` immediately** (produce.rb:98-102,
462
+ with the commented-out `rbbt/tsv/csv` implementation left in place);
463
+ - `:url` → `Open.sensible_write(final_path, Open.open(content, options))` with
464
+ `:noz => true` when the target is compressed (produce.rb:103-106) (remote fetch via
465
+ `Open.wget`/curl, see chunk 2);
466
+ - `:proc` → call with arity 0 or 1 (the target path) (produce.rb:107-113); the result is
467
+ written when it is `String, IO, StringIO` (direct), `Array` (`* "\n"`), `TSV`
468
+ (`data.dumper_stream`) or `TSV::Dumper` (`data.stream`); `nil` is tolerated
469
+ (produce.rb:114-126). Because the `case` evaluates `when TSV` before `when nil`
470
+ (produce.rb:119, 123), a proc returning `nil` in a process where no `TSV` constant
471
+ is defined raises `NameError: uninitialized constant Resource::TSV` — verified P43.
472
+ When a TSV library *is* loaded, the ordering still means a String/IO result is fine
473
+ but any other non-listed object raises `"Unkown object produced: …"` (sic,
474
+ produce.rb:125);
475
+ - `:rake` → `run_rake(path, content, rake_dir)`; failures whose message contains
476
+ `"Don't know how to build task"` are converted to `ResourceNotFound`
477
+ (produce.rb:127-136);
478
+ - `:install` → `software_dir = self.root.software`, `Resource.install(content, name,
479
+ software_dir)`, then `set_software_env(software_dir)` (produce.rb:137-141);
480
+ - anything else raises `"Could not produce #{resource}. (#{type}, #{content})"`
481
+ (produce.rb:142-143).
482
+ The implemented claim types are therefore **`:string`, `:url`, `:proc`, `:rake`,
483
+ `:install`** (plus `:csv` which is a stub). There is no `:annotation` claim type.
484
+ 5. After production the path's location cache is invalidated —
485
+ `path.instance_variable_set("@path", {})` (produce.rb:155) — so that a subsequent
486
+ `find` re-scans all maps (e.g. picking up a `.gz` that just appeared); `path` is
487
+ returned (produce.rb:157).
488
+
489
+ ### 3.5 `lib/scout/resource/produce/rake.rb` — ScoutRake
490
+
491
+ - `Rake::FileTask.define_task` is monkey-patched to record every defined file task in a
492
+ class-level `@@files` list with `Rake::FileTask.files` / `.clear_files`
493
+ (rake.rb:5-23).
494
+ - `module ScoutRake` defines `TaskNotFound < StandardError` (rake.rb:26) and
495
+ `ScoutRake.run(rakefile, dir, task, &block)` (rake.rb:27-68):
496
+ clears `Rake::Task` and the recorded files (rake.rb:30-31), then **forks**. In the
497
+ child: if a block is given it is `instance_exec`'d on the top-level binding receiver
498
+ (rake.rb:36); else the rakefile is either loaded from disk (`rakefile.produce.find`
499
+ first, rake.rb:38-40) or written to a `TmpFile` and loaded (rake.rb:42-45). A missing
500
+ task raises `TaskNotFound` (rake.rb:48); the task is invoked inside `Misc.in_dir(dir)`
501
+ (rake.rb:52), any exception is logged (`Log.exception`, `Log.error`) and the child
502
+ `Kernel.exit!(-1)` (rake.rb:58-62); success exits 0 (rake.rb:63). The parent
503
+ `Misc.wait_child(pid)` and raises `"Rake failed"` unless `$?.success?` (rake.rb:65-66).
504
+ Net effect: rake production happens in a separate process; rake-side exceptions do not
505
+ propagate as-is — the parent sees a generic `"Rake failed"` (which the
506
+ `:rake` arm in produce.rb:130-135 inspects for the "Don't know how to build task"
507
+ text to convert to `ResourceNotFound`).
508
+
509
+ ### 3.6 `lib/scout/resource/open.rb` — auto-produce on Open
510
+
511
+ Reopens `class << Open` aliasing the original `open` as `_just_open` and defining
512
+ `Open.open(file, *args, **kwargs, &block)` that calls `file.produce` when `file` is a
513
+ `Path` before delegating (open.rb:1-9). Combined with `Path#find`'s transparent
514
+ alternative lookup this is why `Open.open(some_unlocated_path)` materialises resources.
515
+
516
+ ### 3.7 `lib/scout/resource/util.rb` — identify / relocate
517
+
518
+ `Resource#identify(path)` (util.rb:2-68) — the inverse of `follow`:
519
+ - non-Path inputs are `Path.setup`; located-ness short-circuits to the input itself
520
+ (util.rb:3-4);
521
+ - uses the path's (or the resource's, or the global) `path_maps`, minus `:current`
522
+ (util.rb:6-11), and `Path.caller_lib_dir` for `{LIBDIR}` (util.rb:13);
523
+ - each map pattern is turned into an anchored regexp: `{TOPLEVEL}` → `(?<TOPLEVEL>[^/]+)`,
524
+ `\.{PKGDIR}` → `\.(?<PKGDIR>[^/]+)` (note the leading dot — matching the `.{PKGDIR}`
525
+ form used by the `:user` map), `{LIBDIR}` substituted literally, any other
526
+ `{GROUP}` → an optional `(?<GROUP>[^/]+)` segment, plus a trailing optional
527
+ `(?<REST>.+)` and optional slash (util.rb:28-34);
528
+ - a match is accepted only when the pattern had no PKGDIR group or the captured pkgdir
529
+ equals `self.final_pkgdir` (util.rb:35-36); the unlocated form is assembled from the
530
+ first non-nil of `TOPLEVEL/SUBPATH/PATH/REST` (util.rb:38-40), the resource `subdir`
531
+ is stripped as a prefix (util.rb:42-46), the result is annotated (util.rb:48) and
532
+ collected as a candidate;
533
+ - the shortest candidate wins (util.rb:55), falling back to the input (util.rb:57);
534
+ - `$HOME` is collapsed to `~` (util.rb:59-63) and the result is re-setup with the
535
+ resource and its path maps (util.rb:65).
536
+
537
+ `Resource.identify(path)` (util.rb:70-75) resolves the resource from `path.pkgdir`,
538
+ falling back to `Resource.default_resource`; `Resource.relocate(path)` (util.rb:77-81)
539
+ returns the path when it exists, else `identify(path).find`.
540
+
541
+ ### 3.8 `lib/scout/resource/software.rb` — `:install` claims
542
+
543
+ - `Resource.install_helpers` → `Scout.share.software.install_helpers.find(:lib)`
544
+ (software.rb:3-5), i.e. the helper shell library shipped in the gem's `share` tree.
545
+ - `Resource.install(content, name, software_dir = Path.setup('software'), &block)`
546
+ (software.rb:7-97): the software dir is resolved with `find(:user)` when it is a Path
547
+ (software.rb:9); a block overrides the content; a Hash content may carry `:name`,
548
+ `:git`, `:src`, `:url`, `:jar`, `:extra`, `:commands`; a remote String is classified as
549
+ `{:git => …}` when it matches `git:|\.git$` else `{:src => …}` (software.rb:24-30).
550
+ The generated bash script is a fixed preamble (`SOFTWARE_DIR`, sourcing
551
+ `INSTALL_HELPER_FILE`, software.rb:13-20) followed by `install_git`/`install_src`/
552
+ `install_jar`/raw-commands sections (software.rb:32-91), executed via
553
+ `CMD.cmd_log('bash', :in => script)` (software.rb:95); `Resource.set_software_env` is
554
+ called afterwards (software.rb:96).
555
+ - `Resource.set_software_env(software_dir = Path.setup('software'))` (software.rb:99-175):
556
+ iterates `software_dir.opt.find_all` (all map locations) in reverse; for each existing
557
+ location it adds `<dir>/opt/bin` to `PATH`, touches `.ld-paths/.c-paths/.pkgconfig-paths/
558
+ .aclocal-paths/.java-classpaths` under `opt` (software.rb:108-119), then reads each of
559
+ those files to extend `C_INCLUDE_PATH`/`CPLUS_INCLUDE_PATH`, `LIBRARY_PATH`/
560
+ `LD_LIBRARY_PATH`/`LD_RUN_PATH`, `PKG_CONFIG_PATH`, `ACLOCAL_FLAGS`, `CLASSPATH`, plus
561
+ every `opt/jars/*.jar` (software.rb:121-156); finally, any `opt/.post_install/*` files
562
+ are scanned for `export VAR=value` lines whose `$var` references are expanded from
563
+ `ENV` and applied (software.rb:158-173). The module calls `self.set_software_env` at
564
+ require time (software.rb:177).
565
+
566
+ ### 3.9 `lib/scout/resource/sync.rb`
567
+
568
+ `Resource.sync(path, map = nil, options = {})` (sync.rb:3-23): `map` defaults to
569
+ `'user'`; the resource comes from `options[:resource]`, else `path.pkgdir` when it is a
570
+ Resource, else `Resource.default_resource` (sync.rb:4-9). The target is
571
+ `resource.identify(path).find(map)` (sync.rb:11). Source selection: the literal path when
572
+ it exists, else `path.directory? ? path.find_all : path.glob_all` (sync.rb:13-18); each
573
+ source is copied with `Open.sync(source, target, options)` (sync.rb:20-22), which is an
574
+ alias of `Open.rsync` (open/sync.rb:81-83) — rsync `-avztHP --copy-unsafe-links
575
+ --omit-dir-times` with the default excludes `.save .crap .source tmp filecache
576
+ open-remote` (open/sync.rb:7, 42-44), `--link-dest` when `:hard_link` (open/sync.rb:43),
577
+ `-nv` when `:test`, optional `--files-from` (open/sync.rb:45-51), plus optional post-hoc
578
+ deletion when `:delete && :files` (open/sync.rb:66-79).
579
+
580
+ ---
581
+
582
+ ## 4. Annotation subsystem
583
+
584
+ ### 4.1 `lib/scout/annotation.rb` — the module-level API
585
+
586
+ - `Annotation.setup(obj, annotation_types, annotation_hash)` (annotation.rb:7-20):
587
+ `annotation_types` may be a `"|"`-joined String, a single type or an Array; each name is
588
+ resolved with `Kernel.const_get` and applied via `type.setup(obj, annotation_hash)`
589
+ (annotation.rb:9-14); unknown names are only warned about (`Log.warn "Annotation #{type}
590
+ not defined"`, annotation.rb:15-16) and skipped. `nil` input returns nil (annotation.rb:8).
591
+ - `Annotation.extended(base)` (annotation.rb:22-26): when a module/class `extend`s
592
+ `Annotation` it gets `@annotations = []`, `include Annotation::AnnotatedObject` and
593
+ `extend Annotation::AnnotationModule` — this is what turns a plain module into an
594
+ annotation *type* with `annotation`, `setup`, `annotated?`, …
595
+ - `Annotation.is_annotated?(obj)` (annotation.rb:28-30): checks
596
+ `obj.instance_variables.include?(:@annotation_types) && obj.respond_to?(:purge)`.
597
+ - `Annotation.purge(obj)` (annotation.rb:32-48): recursively strips annotations from
598
+ Arrays (purging the array itself when annotated, then each element), Hashes (keys and
599
+ values) and annotated leaves (`obj.purge`).
600
+
601
+ ### 4.2 `lib/scout/annotation/annotated_object.rb` — instance side
602
+
603
+ Mixed into every annotation *type*, so into every annotated object:
604
+ - `#annotation_types` → memoised `@annotation_types ||= []` (annotated_object.rb:3-5) —
605
+ populated by `AnnotationModule#extended` (annotation_module.rb:29);
606
+ - `#base_type` → `annotation_types.last` (annotated_object.rb:7-9);
607
+ - `#annotation_hash` → `{name => ivar}` for every name in `@annotations`
608
+ (annotated_object.rb:11-17);
609
+ - `#annotation_info` → `annotation_hash.merge(annotation_types:, annotated_array:)`
610
+ (annotated_object.rb:19-21);
611
+ - `AnnotatedObject.serialize(obj)` / `#serialize` (annotated_object.rb:23-29):
612
+ `Annotation.purge(obj.annotation_info.merge(literal: obj))` — a *plain Hash*
613
+ `{<attr> => value, annotation_types: [...], annotated_array: bool, literal: obj}`
614
+ (verified P42). There is no TSV involved at this level;
615
+ - `#annotation_id` / `#id` → `Misc.digest([self, annotation_info])` (annotated_object.rb:31-35);
616
+ - `#annotate(other)` → apply every own annotation type (with the own attribute values) to
617
+ another object (annotated_object.rb:37-42);
618
+ - `#purge` (annotated_object.rb:44-73): `dup`, then remove every `@<attr>` listed in
619
+ `@annotations` plus `@annotations`, `@annotation_types`, `@container`,
620
+ `@container_index`, and recursively purge every remaining ivar value (annotated_object.rb:67-70).
621
+ The *class* of the object is unchanged — a purged annotated Array is still an Array
622
+ extended with the annotation modules, just without the metadata (verified: after purge
623
+ `Annotation.is_annotated?` is false because `@annotation_types` is gone, but
624
+ `instance_variables` shows the annotated-array state was removed, P36g);
625
+ - `#make_array` → `[self]`, annotated and extended with `AnnotatedArray`
626
+ (annotated_object.rb:75-80).
627
+
628
+ ### 4.3 `lib/scout/annotation/annotation_module.rb` — type definition
629
+
630
+ - `#annotation(*attrs)` (annotation_module.rb:4-11): appends new names to the type's
631
+ `@annotations` (skipping duplicates) and defines attr accessors.
632
+ - `#annotations` → memoised `@annotations ||= []` (annotation_module.rb:13-15).
633
+ - `#included(mod)` / `#extended(obj)` (annotation_module.rb:17-34): propagate the
634
+ attribute list; `extended` additionally pushes `self` onto `obj.annotation_types`
635
+ (annotation_module.rb:29) — this is the only place `@annotation_types` is grown, and it
636
+ explains `Annotation.is_annotated?`'s ivar check.
637
+ - **`#setup(*args, &block)`** (annotation_module.rb:36-69): the workhorse used as
638
+ `MyType.setup(obj, …)`. A leading block is treated as the object (`obj, rest = block,
639
+ args`, annotation_module.rb:37-41); frozen objects are dup'ed (annotation_module.rb:44);
640
+ `obj.extend self` is attempted and a `TypeError` (extending a non-module-friendly
641
+ singleton, e.g. some immediates) simply returns the object un-annotated
642
+ (annotation_module.rb:45-49). Argument binding (annotation_module.rb:54-61): if the last
643
+ rest arg is a 1-element Hash whose first key is an attribute name — or, loosely, when
644
+ the attribute list is not of length 1 (`! attrs.length != 1`, a condition that is true
645
+ whenever `attrs.length != 1` and false when it is exactly 1) — the Hash is used as
646
+ name/value pairs; otherwise attributes are zipped positionally with the rest args.
647
+ Values are assigned as `@<name>` ivars, skipping `nil` values and the reserved
648
+ `:annotation_types` name (annotation_module.rb:63-66).
649
+ Note the aliasing chain: `Path.setup` is exactly this method
650
+ (`Path.setup('a/b').annotation_types == [Path]`, P36f) because `Path` extends
651
+ `Annotation`; `Path.setup` therefore accepts either a Hash of `:pkgdir/:libdir/…` or
652
+ positional values in annotation declaration order (`pkgdir, libdir, path_maps, map_order,
653
+ where, original`, path.rb:8).
654
+
655
+ ### 4.4 `lib/scout/annotation/array.rb` — AnnotatedArray
656
+
657
+ - `AnnotatedArrayItem` (array.rb:3-5) adds `container` / `container_index` accessors;
658
+ `AnnotatedArray.is_contained?(obj)` (array.rb:7-9) tests for it.
659
+ - `#annotate_item(obj, position = nil)` (array.rb:11-19): dup frozen items, extend nested
660
+ Arrays with `AnnotatedArray`, extend with `AnnotatedArrayItem`, record the container and
661
+ index, then `self.annotate(obj)`.
662
+ - Index/iteration overrides that lazily annotate items: `#[]` (with a `clean = false`
663
+ second arg to skip annotation, array.rb:21-25), `#first`, `#last`, `#each_with_index`,
664
+ `#each`, `#inject` (array.rb:27-63).
665
+ - `#collect` is implemented on top of `inject` so that both block and no-block forms work
666
+ (array.rb:65-71); `#select` builds a fresh annotated array (array.rb:41-48).
667
+ - Set-like helpers: `#subset(list)` = `self.annotate(self & list)`, `#remove(list)` =
668
+ `self.annotate(self - list)` (array.rb:73-79).
669
+ - `compact, uniq, flatten, reverse, sort_by` are wrapped so the result is re-annotated and
670
+ re-extended with `AnnotatedArray` (array.rb:81-91).
671
+
672
+ Container representation (settling the suspicion): an annotated *array* is the Array
673
+ object itself extended with `AnnotatedArray` (which in turn was `setup` with the
674
+ annotation types), carrying `@annotations`, `@annotation_types` and the per-attribute
675
+ ivars directly on the Array — **there is no separate container/name/value triplet
676
+ structure**. Items fetched out of an annotated array get `container`/`container_index`
677
+ plus a copy of the annotations via `annotate_item`; `Annotation.purge` removes all of it.
678
+
679
+ ### 4.5 What `:annotation` persistence actually needs
680
+
681
+ `Persist.serialize(…, :annotation)` calls `Annotation.tsv(content, :all).to_s`
682
+ (serialize.rb:37-38) and `Persist.deserialize` calls
683
+ `Annotation.load_tsv(TSV.open(serialized))` (serialize.rb:74-75). Neither `Annotation.tsv`
684
+ nor `Annotation.load_tsv` nor `TSV` itself is defined anywhere in this gem (`grep`
685
+ `def tsv|load_tsv` under `lib/scout/annotation*` returns nothing; `Object.const_defined?(:TSV)`
686
+ is false, P42). The `:annotation` type is therefore **external-library dependent and
687
+ non-functional standalone** — `Persist.persist(…, :annotation, …)` raises
688
+ `NoMethodError: undefined method 'tsv' for module Annotation` (verified P38), and
689
+ `Resource`'s `:proc` claim dispatcher similarly trips over the `TSV` constant
690
+ (produce.rb:119-122, verified P43).
691
+
692
+ ---
693
+
694
+ ## 5. Cross-cutting notes and doc-suspicion hot-spots
695
+
696
+ 1. **`Path#find` never returns nil.** Located paths return `self`; unlocated paths
697
+ fall through to `follow(:default)` (the `:user` map) even when nothing exists
698
+ (find.rb:247-274, P34). Docs claiming "returns nil if not found" are wrong.
699
+ 2. **Default search order** is *derived*, not the `path_maps` declaration order, and
700
+ `:default`/`:tmp` trail at the end (find.rb:103-119, P34/P39). `basic_map_order`
701
+ contains a `workflow` entry with no default map.
702
+ 3. **No `Path.map_order=`** — only `add_path`/`prepend_path`/`append_path` mutate it
703
+ (P39). Instance-level `path_maps=`/`map_order=` also do not exist; you assign the
704
+ per-instance copy via `p.path_maps = {...}` only because `path_maps` is a declared
705
+ annotation attr (path.rb:8) — there is no equivalent for `map_order` on instances
706
+ beyond `prepend_path`.
707
+ 4. **`Persist.persistence_path(name, :marshal)` is invalid** (P41); the type belongs to
708
+ `Persist.persist`'s second positional parameter.
709
+ 5. **`Persist` default serializer is `:json`** (serialize.rb:7), while `Persist.load`'s
710
+ `:serializer` branch uses `Open.marshal` (serialize.rb:144-145) — a mismatch to
711
+ double-check in docs: `load(file)` with no type marshals, `persist(name)` with no type
712
+ JSON-serialises.
713
+ 6. **`Persist.deserialize(…, :yaml)` returns a Psych AST node** (serialize.rb:69, P35),
714
+ unlike `Persist.load(file, :yaml)` which returns real objects.
715
+ 7. **`:annotation` persistence is unimplemented standalone** (serialize.rb:37-38, 74-75;
716
+ P38, P42); **`:csv` claims are a stub** that raises immediately (produce.rb:98-102, P37b).
717
+ 8. **Implemented claim types**: `:string`, `:url`, `:proc`, `:rake`, `:install`
718
+ (produce.rb:95-141). `:proc` returning `nil` mis-fires on the undefined `TSV`
719
+ constant (produce.rb:119-123, P43).
720
+ 9. **`Path#produce` memoisation uses `@produced` tri-state**: `true` on success/no-op,
721
+ `false` after `ResourceNotFound`, an Exception object stored and re-raised on later
722
+ calls (resource/path.rb:2-21, P37c).
723
+ 10. **Extension fall-through is asymmetric**: `Resource#produce` retries `.gz` then `.bgz`
724
+ for unclaimed un-compressed names (produce.rb:64-73), so asking for `x` when `x.gz`
725
+ is claimed produces the gzip file and `find` then returns `x.gz`; asking for `x.gz`
726
+ when only `x` is claimed yields a `false` produce result (P37c).
727
+ 11. **Produce locks** live in `Resource#lock_dir` (default `$HOME/.scout/tmp/produce_locks`)
728
+ and are named after the *final* path via TmpFile digest naming (produce.rb:87);
729
+ **persist locks** live in `Persist.lock_dir` (`$HOME/.scout/tmp/persist_locks`) with
730
+ a `.persist` suffix (persist.rb:61) — two distinct lock namespaces.
731
+ 12. **Rake production is forked**; parent-side failures surface as the generic
732
+ `"Rake failed"` string which produce.rb:130-135 string-matches to convert
733
+ "Don't know how to build task" into `ResourceNotFound` (rake.rb:27-68).
734
+ 13. **`Open.open` auto-produces Paths** (resource/open.rb:4-9), and `Open.yaml`/`Open.json`
735
+ transparently use `find_with_extension` (persist/open.rb:7, 12).
736
+ 14. **`Resource.identify` collapses `$HOME` to `~`** (util.rb:59-63) and strips the
737
+ resource `subdir` prefix (util.rb:42-46); its regexp treats `.{PKGDIR}` (dot-prefixed)
738
+ specially (util.rb:31).
739
+ 15. **`Path#digest_str` wraps non-existent paths in literal single quotes**
740
+ (digest.rb:20) — the output is `'<path>'`, quotes included.
741
+ 16. **`Annotation.setup` swallows unknown type names with a warning** (annotation.rb:15-16),
742
+ and `AnnotationModule#setup` returns un-annotated objects when `extend` raises
743
+ `TypeError` (annotation_module.rb:47-49).
744
+ 17. **`AnnotatedArray#[]`'s second parameter is `clean`**, not a length: `arr[0, true]`
745
+ returns the raw un-annotated item (array.rb:21-25).
746
+
747
+ ---
748
+
749
+ ## Files covered checklist
750
+
751
+ - [x] lib/scout/path.rb
752
+ - [x] lib/scout/path/digest.rb
753
+ - [x] lib/scout/path/find.rb
754
+ - [x] lib/scout/path/tmpfile.rb
755
+ - [x] lib/scout/path/util.rb
756
+ - [x] lib/scout/persist.rb
757
+ - [x] lib/scout/persist/open.rb
758
+ - [x] lib/scout/persist/path.rb
759
+ - [x] lib/scout/persist/serialize.rb
760
+ - [x] lib/scout/resource.rb
761
+ - [x] lib/scout/resource/open.rb
762
+ - [x] lib/scout/resource/path.rb
763
+ - [x] lib/scout/resource/produce.rb
764
+ - [x] lib/scout/resource/produce/rake.rb
765
+ - [x] lib/scout/resource/scout.rb
766
+ - [x] lib/scout/resource/software.rb
767
+ - [x] lib/scout/resource/sync.rb
768
+ - [x] lib/scout/resource/util.rb
769
+ - [x] lib/scout/annotation.rb
770
+ - [x] lib/scout/annotation/annotated_object.rb
771
+ - [x] lib/scout/annotation/annotation_module.rb
772
+ - [x] lib/scout/annotation/array.rb
773
+
774
+ (22/22.)