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,184 @@
1
+ # Annotation System
2
+
3
+ Annotations attach named metadata to **ordinary Ruby objects** without
4
+ wrapping them and without changing their class. The same mechanism is what
5
+ makes `Path`, `Resource` and `NamedArray` work, so understanding it is a
6
+ prerequisite for [Path Resolution](PathResolution.md) and
7
+ [Persistence and Resources](PersistenceAndResources.md).
8
+
9
+ Everything on this page is backed by `tmp/rewrite_C/probe_01..04.rb` and
10
+ probes P36–P42 in `research/behavior-probes.md`, run against the current
11
+ `lib/scout/annotation*.rb`.
12
+
13
+ ## Load path
14
+
15
+ `lib/scout-essentials.rb` does **not** require `scout/annotation` directly.
16
+ `Annotation` becomes available because `scout/path` requires it
17
+ (`lib/scout/path.rb:1`), so `require 'scout-essentials'` gives you the
18
+ constant (`Object.const_defined?(:Annotation) => true`, probe_03) — but a
19
+ library that only wants annotations can `require 'scout/annotation'` on its
20
+ own.
21
+
22
+ ## The DSL
23
+
24
+ ```ruby
25
+ module SampleInfo
26
+ extend Annotation
27
+ annotation :organism, :tissue
28
+ end
29
+ ```
30
+
31
+ `extend Annotation` turns the module into an **AnnotationModule**. The
32
+ `annotation` class-level call does three things (annotation_module.rb):
33
+
34
+ - declares the attribute list, kept in module state `@annotations` and read
35
+ with `SampleInfo.annotations` (there is **no `ANNOTATIONS` constant**);
36
+ - defines reader/writer methods (`#organism`, `#organism=`) for when the
37
+ module is mixed into an object;
38
+ - registers the module so `Annotation.setup` can resolve type names.
39
+
40
+ ## `setup` and the binding rules
41
+
42
+ `AnnotationModule#setup(obj, values = nil, &block)` is the constructor. The
43
+ values may be given as a Hash or positionally, and a block is an alternative
44
+ source for the object itself:
45
+
46
+ ```ruby
47
+ SampleInfo.setup('S003', organism: 'Human', tissue: 'Liver')
48
+ SampleInfo.setup('S004', %w[Human Liver]) # positional, same order
49
+ SampleInfo.setup { 'S005' } # block-as-object
50
+ ```
51
+
52
+ Rules verified in probe_01/probe_02:
53
+
54
+ - **In-place extension.** `setup` calls `obj.extend SampleInfo` on the object
55
+ you pass and returns **that same object** (`setup(s).equal?(s) => true`) —
56
+ except when the object is **frozen**, in which case `obj.dup` is annotated
57
+ and returned instead; always use the return value of `setup`.
58
+ - **TypeError → un-annotated.** Objects that cannot hold singleton methods
59
+ (`Integer` literals, symbols) raise `TypeError: can't define singleton`;
60
+ `setup` rescues it and returns the *plain* object with no metadata.
61
+ - **`:annotation_types` is reserved.** Each annotated object gets an
62
+ `@annotation_types` array; declaring `annotation :annotation_types` collides
63
+ with it (probe_01: writing to it raised `NoMethodError` against `nil`).
64
+
65
+ ## Introspection and serialisation
66
+
67
+ State lives on the object, the type list on the object too:
68
+
69
+ ```ruby
70
+ s = SampleInfo.setup('S003', organism: 'Human', tissue: 'Liver')
71
+
72
+ SampleInfo.annotations # => [:organism, :tissue] (module state)
73
+ s.annotation_types # => [SampleInfo] (module objects!)
74
+ Annotation.is_annotated?(s) # => true
75
+ s.is_a?(SampleInfo) # => true (extend really happened)
76
+ s.is_a?(String) # => true (class unchanged)
77
+ ```
78
+
79
+ `annotation_types` holds the **module objects**, never their names —
80
+ `s.annotation_types.include?(SampleInfo)` is the correct membership test.
81
+
82
+ `AnnotatedObject` (mixed into every annotated object) provides:
83
+
84
+ ```ruby
85
+ s.annotation_hash # => {:organism=>"Human", :tissue=>"Liver"}
86
+ s.annotation_info # => {:organism=>"Human", :tissue=>"Liver",
87
+ # :annotation_types=>[SampleInfo], :annotated_array=>false}
88
+ s.serialize # => {...same, :literal=>"S003"} (values purged recursively)
89
+ s.annotation_id # => "860c73f490edb8115064663b7f579d73"
90
+ ```
91
+
92
+ - `annotation_hash` — the declared attributes only;
93
+ - `annotation_info` — plus `:annotation_types` and `:annotated_array`;
94
+ - `serialize` — `annotation_info` merged with `:literal` (the object itself),
95
+ with every value passed through `Annotation.purge` (annotated_object.rb:23);
96
+ this is what the `:annotation` serialisation driver consumes. It is **only a
97
+ Hash here**; the TSV form (`Annotation.tsv` / `Annotation.load_tsv`) lives in
98
+ scout-gear, not in this repo (attribution table in
99
+ [Architecture](Architecture.md));
100
+ - `annotation_id` (aliased `id`) — `Misc.digest([self, annotation_info])`.
101
+
102
+ ### Module-level helpers
103
+
104
+ - `Annotation.is_annotated?(obj)` — true if any annotation module is mixed in.
105
+ - `Annotation.purge(obj)` — **recursive**: for an annotated object it calls the
106
+ instance `#purge`; for Arrays/Hashes it purges each element; otherwise it
107
+ returns the object unchanged.
108
+ - `Annotation.setup(obj, "A|B", hash)` — the generic deserialiser. The type
109
+ string is split on `|`, each name resolved with `Object.const_get`. **Unknown
110
+ names only warn and are skipped** (`Log.warn "Annotation NoSuchAnnotation not
111
+ defined"`, probe_01) — no exception.
112
+
113
+ ### Instance-side helpers
114
+
115
+ - `#purge` — returns a **copy**: it removes `@annotation_types`, `@annotations`
116
+ and every attribute ivar from a `dup` (and purges nested values) and returns
117
+ it. The object's own class is kept (a purged `SampleInfo` String is still a
118
+ `String`, no longer `is_a?(SampleInfo)`); always use the return value.
119
+ - `#make_array` — wraps `self` in a **one-element Array** carrying the same
120
+ annotations and extends it with `AnnotatedArray` (annotated_object.rb:75-80);
121
+ it does not annotate the receiver's elements.
122
+ - `#annotate(other)` — copies the current annotations onto another object.
123
+
124
+ ## Round-trips and copies
125
+
126
+ - **Marshal round-trips** annotations (P42, probe_01): the singleton modules
127
+ survive dump/load.
128
+ - **Only `dup` loses them; `clone` keeps them** (probe_02:
129
+ `dup is_annotated? => false`, `clone is_annotated? => true, .a => 1` —
130
+ `clone` copies the singleton class, `dup` does not): to re-annotate a `dup`
131
+ copy the metadata explicitly with `annotation_hash` →
132
+ `Annotation.setup` / `MyModule.setup`, or call `#annotate` on the copy.
133
+
134
+ ## `AnnotatedArray`
135
+
136
+ `extend AnnotatedArray` on an annotated Array — the pattern used throughout
137
+ `test/scout/annotation/test_array.rb` — makes the *elements* carry the
138
+ container's annotations:
139
+
140
+ ```ruby
141
+ arr = SampleInfo.setup(%w[S001 S002], organism: 'Human')
142
+ arr.extend AnnotatedArray
143
+ arr[0].organism # => "Human"
144
+ arr.first.organism # => "Human"
145
+ ```
146
+
147
+ Overrides provided in `lib/scout/annotation/array.rb`:
148
+
149
+ - `[]` `(pos, clean = false)` — the element is re-annotated unless the second
150
+ argument is truthy, in which case it is returned clean (probe_09: a fresh
151
+ array's `fresh[0, true]` is an un-annotated `String` while `fresh[1]` is
152
+ annotated);
153
+ - `first`, `last`, `each_with_index`, `each`, `inject`, `collect`, `select` —
154
+ re-annotated;
155
+ - `compact`, `uniq`, `flatten`, `reverse`, `sort_by` — re-annotated;
156
+ - `subset(list)`, `remove(list)` — set operations (`&`, `-`) with
157
+ re-annotation.
158
+
159
+ **Limits** — live probe `tmp/rewrite_C/probe_09_annotated_array.rb` (method
160
+ owners plus actual results): `map`, `zip`, `filter_map`, `flat_map`,
161
+ `each_slice`, `values_at` and `count` are **not overridden** (their owner is
162
+ `Array`/`Enumerable`) and return plain results with no annotations — `ary.map
163
+ { |x| x }` yields `[false, false, false]` under `Annotation.is_annotated?`,
164
+ while `ary.each` yields `[true, true, true]`, and `zip` keeps annotations only
165
+ on the container-side elements.
166
+
167
+ **Requirement:** elements must be extendable. `AnnotatedArray` over an Array
168
+ of `Integer`s raises `TypeError: can't define singleton` (probe_09) — use
169
+ Strings or other extendable objects.
170
+
171
+ ## NamedArray is a separate thing
172
+
173
+ `NamedArray` (`lib/scout/named_array.rb`) is an Annotation module over Arrays
174
+ giving field-name access to positions. It does **not** extend
175
+ `AnnotatedArray` and is not a String type. It needs an explicit
176
+ `require 'scout/named_array'`. See [Annotating
177
+ Data](../user/AnnotatingData.md).
178
+
179
+ ## Related
180
+
181
+ - [Path Resolution](PathResolution.md) — `Path` is an annotated module.
182
+ - [Persistence and Resources](PersistenceAndResources.md) — `Resource` is an
183
+ annotated module; `:annotation` serialisation uses `serialize`.
184
+ - [Annotating Data](../user/AnnotatingData.md) — the user-facing API.
@@ -0,0 +1,147 @@
1
+ # Architecture
2
+
3
+ This page describes how the parts of scout-essentials fit together: the
4
+ module dependency graph, the load order, and where the boundaries to the
5
+ rest of the Scout ecosystem are. Everything here is derived from the source
6
+ under `lib/`.
7
+
8
+ ## Modules and dependencies
9
+
10
+ ```
11
+ IndiferentHash (no internal dependencies)
12
+ ^ ^
13
+ | \-----------------\
14
+ Annotation (standalone) \
15
+ ^ \
16
+ | \
17
+ Misc::Digest <- Path -----> Resource (annotation-based)
18
+ ^ ^ ^ ^ ^
19
+ | | | | \
20
+ TmpFile --> Open --> CMD --> ConcurrentStream
21
+ ^ ^
22
+ | |
23
+ Lockfile (required BY CMD, not the other way round)
24
+ ```
25
+
26
+ Reading the edges as "requires":
27
+
28
+ - `IndiferentHash` requires nothing else internally.
29
+ - `Annotation` is a standalone mixin framework; `Path` and `Resource`
30
+ *extend* it, which is how `Path`/`Resource` metadata (pkgdir, libdir,
31
+ `@where`, `@original`, ...) attaches to plain Strings. This
32
+ **annotation-based unification** means a `Path` object is just a String
33
+ carrying annotations, and `Resource` is a module (not a class) whose
34
+ class-level state (`pkgdir`, `rake_dirs`, `path_maps`, `map_order`,
35
+ `lock_dir`) is read through those annotations.
36
+ - `Path` requires `Misc::Digest` (for `digest` naming) and the annotation
37
+ framework.
38
+ - `TmpFile` requires `Open`; the arrow therefore points **TmpFile → Open**
39
+ (not "Open depends on TmpFile").
40
+ - `Open` requires `Path` and `CMD`; `Open::Remote` shells out through
41
+ `CMD`. `Open` *uses* `TmpFile` helpers at runtime but does not require the
42
+ file.
43
+ - `CMD` requires `ConcurrentStream` (and `Open::Stream`); `ConcurrentStream`
44
+ does not require `CMD`.
45
+ - `Resource` requires `Log` and `Path` only — it does **not** depend on
46
+ `Persist`.
47
+ - `Persist` requires `Open` (and through it `CMD`); it never touches
48
+ `Resource`.
49
+ - `Log` colors come from `term-ansicolor`; there is **no `Log::Color`
50
+ constant**.
51
+ - The `Lockfile` implementation is **vendored** at
52
+ `lib/scout/open/lock/lockfile.rb` and used by `Open.lock`.
53
+
54
+ ## Load order and entry points
55
+
56
+ `lib/scout-essentials.rb` is the **only** entry point; there is no
57
+ `lib/scout.rb`:
58
+
59
+ ```ruby
60
+ require_relative 'scout/exceptions'
61
+ require_relative 'scout/indiferent_hash'
62
+ require_relative 'scout/tmpfile'
63
+ require_relative 'scout/log'
64
+ require_relative 'scout/path'
65
+ require_relative 'scout/simple_opt'
66
+ require_relative 'scout/resource'
67
+ require_relative 'scout/resource/scout'
68
+ require_relative 'scout/persist'
69
+ require_relative 'scout/config'
70
+ ```
71
+
72
+ Notes on the cascade:
73
+
74
+ - `Annotation`, `Misc`, `CMD`, `ConcurrentStream`, `Open` and `Persist`'s
75
+ sub-files are pulled in indirectly (e.g. `scout/path` requires
76
+ `scout/annotation`; `scout/persist` requires `scout/persist/serialize`,
77
+ `scout/persist/open`, `scout/persist/path`).
78
+ - `scout/resource/scout` defines `Scout` (`extend Resource`, `pkgdir =
79
+ 'scout'`, `Resource.default_resource = Scout`) and loads
80
+ `Path.load_path_maps(Scout.etc['path_maps'])`.
81
+ - `Misc::NamedArray` and `Misc::Hook` are **not** auto-loaded; they live in
82
+ `lib/scout/named_array.rb` and `lib/scout/misc/hook.rb` and are required
83
+ explicitly by the file that needs them.
84
+ - `scout/config` runs last and provides the `Scout::Config` module (not a top-level `Config` constant).
85
+
86
+ ## Lock namespaces
87
+
88
+ There are three distinct lock directories, all under `$HOME/.scout/tmp`:
89
+
90
+ | Namespace | Constant/definition | Used by |
91
+ |-----------|---------------------|---------|
92
+ | `tmp/persist_locks` | `Persist.lock_dir` | `Persist.persist` while (re)computing a cache entry |
93
+ | `tmp/produce_locks` | `Resource.default_lock_dir` | `Resource#produce` while materializing a resource |
94
+ | `tmp/sensible_write_locks` | `Open.sensible_write_lock_dir` | `Open.sensible_write` atomic writes |
95
+
96
+ `Persist.lock` does **not exist** — the locking primitive is
97
+ `Open.lock(filename, &block)`, built on the vendored `Lockfile`.
98
+
99
+ ## Fork model
100
+
101
+ `CMD` runs subprocesses through `Open3`, wiring stdout/stderr into
102
+ `ConcurrentStream`s so that streaming consumers can read while the process
103
+ is alive. Aborting a `CMD` kills the child process and aborts the attached
104
+ streams. `Resource#produce` for rake claims forks a `ScoutRake` invocation,
105
+ so a failed rake does not leave partial files behind
106
+ (`Open.sensible_write` removes them).
107
+
108
+ ## Packaging
109
+
110
+ `scout-essentials.gemspec` is generated by juwelier from the `Juwelier::Tasks`
111
+ block in the `Rakefile` ("DO NOT EDIT THIS FILE DIRECTLY"). That block lists
112
+ four runtime dependencies — `term-ansicolor`, `yaml`, `rake`, `listen` — and
113
+ the checked-in gemspec mirrors them as `add_runtime_dependency` entries
114
+ (lines 172-175). Each is actually used from `lib/`: `term/ansicolor`
115
+ (`scout/log/color.rb`), `yaml` (`scout/persist/open.rb`,
116
+ `scout/log/progress/report.rb`), `rake` (`scout/resource/produce/rake.rb`),
117
+ `listen` (`scout/open/util.rb`, loaded lazily when a watcher is requested).
118
+ Nothing else is required at runtime; in particular scout-essentials does
119
+ not depend on scout-gear, rbbt-util, or any TSV/Workflow/Step
120
+ implementation.
121
+
122
+ ## Ecosystem boundaries and attribution
123
+
124
+ scout-essentials is a **substrate library**. The following well-known Scout
125
+ concepts are **not** implemented here; each row says where they actually
126
+ live:
127
+
128
+ | Concept | Where it lives |
129
+ |---------|----------------|
130
+ | `TSV`, `TSV::Dumper`, `Annotation.tsv` | scout-gear (`scout/tsv`) |
131
+ | `Workflow`, `Task`, `Step`, `Step#info` / `.info` files | scout-gear (`scout/workflow`) |
132
+ | Job/step persistence beyond `Persist` (`.info` directory, `files/`) | scout-gear |
133
+ | HPC / scheduler (`slurm`, `lsf`, `pbs`, orchestrators) | scout-gear / scout-rig |
134
+ | `notify` / `send_email` helpers | scout-gear |
135
+ | `Bgzf` (blocked gzip) | scout-gear |
136
+ | `deep_indifferent` | **does not exist anywhere in the ecosystem** |
137
+ | Scout CLI (`scout` executable, `scout_commands/`) | scout-gear |
138
+ | rbbt-util shims (e.g. `rbbt-util` compatibility aliases) | UNVERIFIED — not present in this repo's `lib/` |
139
+
140
+ When writing documentation or examples for this repo, do not present those
141
+ as features of scout-essentials.
142
+
143
+ ## Related
144
+
145
+ - [Path Resolution](PathResolution.md) for the `Path`/`Resource` machinery.
146
+ - [Persistence and Resources](PersistenceAndResources.md) for `Persist` and
147
+ `Resource#produce` contracts.
@@ -0,0 +1,238 @@
1
+ # Configuration
2
+
3
+ `Scout::Config` is the registry behind every option the Scout stack reads:
4
+ it is a flat `key -> [tokens, value]` store with one rule — **among the
5
+ entries whose token matches the caller's lookup context, the entry with the
6
+ LOWEST priority number wins**.
7
+
8
+ Source: `lib/scout/config.rb` (183 lines, whole file), plus
9
+ `lib/scout/resource/scout.rb` for `Scout.etc` (`Path.setup("etc")` — see
10
+ [PathResolution.md](PathResolution.md)).
11
+
12
+ ## The token priority table
13
+
14
+ `Scout::Config.token_priority(token)` (`lib/scout/config.rb:38-67`) splits a
15
+ token on `::` and returns `[token, priority]`:
16
+
17
+ | token | priority | who sets it |
18
+ |---|---|---|
19
+ | `line:<file>:<line>` | 1 | derived from the caller of `Config.get` |
20
+ | `file:<file>` | 2 | derived from the caller of `Config.get` |
21
+ | `task:<name>` | 3 | passed by higher layers (e.g. a workflow task) |
22
+ | `workflow:<name>` | 4 | passed by higher layers |
23
+ | anything else | 10 | free-form custom tokens |
24
+ | `key:` / `key:<name>` | 20 | the key itself; LOWEST priority |
25
+ | `token::N` | N | explicit numeric suffix always wins over the defaults |
26
+
27
+ Verified by `tmp/rewrite_D/probe_11_token_probe.rb`:
28
+
29
+ ```text
30
+ workflow:x => ["workflow:x", 4]
31
+ task:x => ["task:x", 3]
32
+ file:x => ["file:x", 2]
33
+ line:x => ["line:x", 1]
34
+ key:zz => ["key:zz", 20]
35
+ other:x => ["other:x", 10]
36
+ explicit ::5 => ["workflow:x", 5]
37
+ ```
38
+
39
+ Lower number = higher precedence, because `Config.get` picks
40
+ `priorities.sort_by{|p,v| p}.first` (`lib/scout/config.rb:132`). The `key:`
41
+ token (20) is deliberately the weakest, so any context entry can override a
42
+ plain value.
43
+
44
+ ## A worked example
45
+
46
+ A common misreading is "workflow overrides task". The numbers say the
47
+ opposite — `line` (1) outranks `workflow` (4). `tmp/rewrite_D/probe_13_get_internals.rb`
48
+ stacks all four tokens on one key and shows the chain end-to-end:
49
+
50
+ ```ruby
51
+ def fresh(k); Scout::Config::CACHE.delete(k); end
52
+
53
+ k='q1'; fresh k
54
+ Scout::Config.set({k=>'wfv'},'workflow:W')
55
+ Scout::Config.set({k=>'taskv'},'task:T')
56
+ Scout::Config.set({k=>'filev'},'file:F')
57
+ Scout::Config.set({k=>'linev'},'line:L')
58
+ Scout::Config.get(k,'workflow:W','task:T','file:F','line:L')
59
+ # => "linev" (line=1 wins)
60
+
61
+ k='q2'; fresh k
62
+ Scout::Config.set({k=>'wfv'},'workflow:W')
63
+ Scout::Config.set({k=>'taskv'},'task:T')
64
+ Scout::Config.set({k=>'filev'},'file:F')
65
+ Scout::Config.get(k,'workflow:W','task:T','file:F')
66
+ # => "filev" (file=2 wins)
67
+
68
+ k='q3'; fresh k
69
+ Scout::Config.set({k=>'wfv'},'workflow:W')
70
+ Scout::Config.set({k=>'taskv'},'task:T')
71
+ Scout::Config.get(k,'workflow:W','task:T')
72
+ # => "taskv" (task=3 wins)
73
+ ```
74
+
75
+ Within one priority bucket the LAST `set` wins (`priorities[prio].unshift`,
76
+ `lib/scout/config.rb:83`; verified `probe_13` `two task entries => "second"`).
77
+
78
+ Explicit suffixes punch through the table, both ways — `::1` promotes an
79
+ entry above every default token, `::30` buries it below `key:` (probe_13,
80
+ cases `q6`/`q7`). Note the suffix is per-entry, not per-key: it re-prices one
81
+ `set` call, it does not change the token for later calls.
82
+
83
+ ### Implicit caller tokens
84
+
85
+ Every `Config.get` appends `file:<caller>` and `line:<caller>:<n>` derived
86
+ from `caller` (`lib/scout/config.rb:119-127`), after filtering out frames
87
+ matching the legacy rbbt regexes (see *Legacy internals* below). Entries set
88
+ with those tokens match without being named in the call (verified
89
+ `probe_12`, `matched by implicit caller file token => "v"`).
90
+
91
+ ## `etc/config` files
92
+
93
+ `Scout.etc.config` is `Path.setup("etc").config` — typically
94
+ `$HOME/.scout/etc/config`. Lines are `key value token...`, `#` comments,
95
+ whitespace-separated (`load_file`, `lib/scout/config.rb:15-25`):
96
+
97
+ ```text
98
+ # comment
99
+ key value token
100
+ ```
101
+
102
+ `load_config` walks `Path.setup("etc").config.find_all.reverse`
103
+ (`lib/scout/config.rb:27-31`). `find_all` returns paths in `map_order`
104
+ (`current, user, home, local, global, usr, lib, fast, cache, bulk, ...`), so
105
+ the **reverse** order loads the *most specific* file (e.g. `$PWD/etc/config`)
106
+ **first** and `$HOME/.scout/etc/config` last. Two files setting the same key
107
+ with the same (implicit `key:`) token were tested by
108
+ `tmp/rewrite_D/probe_23_findall_order.rb`:
109
+
110
+ ```text
111
+ map_order => [:current, :user, :home, ...]
112
+ find_all => [/tmp/.../etc/config, /home/.../.scout/etc/config]
113
+ loaded order => find_all.reverse (HOME first, CWD last)
114
+ CACHE['shared'] => [key-token fromHOME, key-token fromCWD]
115
+ get('shared') => "fromCWD-currentmap"
116
+ ```
117
+
118
+ The `current` map wins even though it was loaded last, because `add_entry`
119
+ **appends** and `match` `unshift`s each matching value, so the last-loaded
120
+ entry lands at the *front* of the priority bucket and `get` takes
121
+ `priorities.sort_by.first`. In short: **later-loaded (more specific) files
122
+ win ties**.
123
+
124
+ `Config.set({key => value}, *tokens)` is the programmatic equivalent; with
125
+ one key it auto-adds the `key:<name>` token (`add_entry`,
126
+ `lib/scout/config.rb:11-19`).
127
+
128
+ ## `Config.get` value post-processing
129
+
130
+ `Config.get(key, *tokens)` (`lib/scout/config.rb:94-155`) does four things
131
+ after resolving the winner:
132
+
133
+ 1. **`'false'` becomes `false`** — the ONLY string-to-object coercion.
134
+ Verified in `tmp/rewrite_D/probe_10_env.rb`: `'false'` → `false`
135
+ (FalseClass), but `'TRUE'`, `'0'` and `'42'` all stay Strings.
136
+ 2. **`'nil'` becomes `nil`** (probe_10).
137
+ 3. **`env:VAR[,VAR2]`** in the resolved value is replaced by the first set
138
+ environment variable, and `get` returns `nil` if none is set (probe_10,
139
+ `'from-env'`).
140
+ 4. Every `get` appends `[key, value, tokens]` to `GOT_KEYS` — an audit trail
141
+ of what was asked and answered (probe_10). `with_config` snapshots and
142
+ restores it.
143
+
144
+ Options hash as last argument:
145
+
146
+ - `:default => v` — used when no entry matches.
147
+ - `:env => 'VAR1,VAR2'` — first set env var becomes the default (probe_10).
148
+
149
+ A `Symbol` key raises `TypeError` — the cache is keyed by `key.to_s`, but
150
+ `CACHE[key.to_s]` is called after `match` has already used the raw key;
151
+ `Config.get(:symbol)` raises `TypeError: no implicit conversion of Symbol
152
+ into String` (probe_10). Use String keys.
153
+
154
+ ## `with_config` and `process_config`
155
+
156
+ `with_config { ... }` (`lib/scout/config.rb:157-166`) dups every `CACHE`
157
+ bucket and `GOT_KEYS`, yields, and restores both in an `ensure`. Anything
158
+ `set` inside the block is rolled back — including sets made by code you
159
+ call (probe_10: `inside => "inside"`, `after => "outside"`). It is the
160
+ mechanism that keeps config changes from leaking out of a sub-operation.
161
+
162
+ `Config.process_config(config)` (`lib/scout/config.rb:168-180`) interprets
163
+ a CLI `--config` argument, in this order:
164
+
165
+ 1. existing file path → `load_file`
166
+ 2. name of an existing `Scout.etc.config_profile[config]` file → `load_file`
167
+ 3. otherwise a `"key value token..."` string. The `::N` suffix is mandatory
168
+ in the stored token: any token without one gets `prio = "0"`, i.e. the
169
+ token is re-emitted as `token::0`.
170
+
171
+ Verified by `tmp/rewrite_D/probe_17b.rb` / `probe_17c.rb`. The subtle part
172
+ of case 3 is that the stored token keeps the suffix, so `process_config
173
+ "k3 cli workflow::0"` stores the token `workflow::0`, whose *name* is the
174
+ bare string `workflow` at priority 0 (stronger than `task`=3,
175
+ `file`=2, `line`=1, `key:`=20), and it does **not** match a call like
176
+ `get('k3', 'workflow:W')`, because the token *names* differ
177
+ (`workflow::0` vs `workflow:W`). Quoting a concrete outcome:
178
+
179
+ ```text
180
+ CACHE[k3] => [[["workflow::0", "key:k3"], "cli"]]
181
+ token_priority('workflow::0') => ["workflow", 0]
182
+ get(k3) => "cli" (via the implicit key: token)
183
+ get(k3, 'workflow') => "cli" (prio 0 beats key:=20)
184
+ get(k3, 'workflow:W') => nil (token name mismatch)
185
+ ```
186
+
187
+ So a CLI `-config "key val workflow::0"` overrides anything except entries
188
+ carrying an explicit smaller `::N` — but only for lookups that pass the bare
189
+ `workflow` token.
190
+
191
+ ## Environment variables read by the gem
192
+
193
+ Verified by grep over `lib/` and, where noted, executed probes
194
+ (`tmp/rewrite_D/probe_10_env.rb` covers the `env:`/`:env` machinery; the
195
+ rest are read directly from source):
196
+
197
+ | variable | read by | effect |
198
+ |---|---|---|
199
+ | `SCOUT_LOG` | `lib/scout/log.rb:35` | sets default log severity |
200
+ | `SCOUT_LOG_INSIST` | `lib/scout/misc/insist.rb:35` | logs each insist retry exception |
201
+ | `SCOUT_NOCOLOR` | `lib/scout/log/color.rb:137` (also `misc/format.rb:28`) | disables colors / unicode seconds glyph |
202
+ | `SCOUT_NO_PROGRESS` | `lib/scout/log/progress.rb:11` | disables progress bars |
203
+ | `SCOUT_ORIGINAL_STACK` | `lib/scout/log.rb:261,319` | keeps original exception backtrace |
204
+ | `SCOUT_DEBUG_PID` | `lib/scout/log.rb:178` | prefixes messages with the pid |
205
+
206
+ All are tested with `== 'true'` (except `SCOUT_LOG`, a severity name), so
207
+ any other value is false.
208
+
209
+ `Config.get` itself reads no env var unless `:env` is given or the value is
210
+ an `env:` string.
211
+
212
+ ## Legacy internals
213
+
214
+ The caller-token filter in `Config.get` (`lib/scout/config.rb:119-127`)
215
+ still names `rbbt/...` files in its regexes. That is a leftover from the
216
+ upstream project this gem was extracted from; the effect here is only that
217
+ frames whose path matches those patterns are skipped when deriving
218
+ `file:`/`line:` tokens. Nothing else in `scout-essentials` depends on it
219
+ (marked INTERNAL in the audit matrix).
220
+
221
+ ## Thread-safety
222
+
223
+ There is no mutex anywhere in `lib/scout/config.rb` (`Scout::Config` has no
224
+ `Mutex` ivar; verified `tmp/rewrite_D/probe_09_threads_fork.rb`). `CACHE`
225
+ and `GOT_KEYS` are plain shared structures and `add_entry` does
226
+ read-modify-write (`CACHE[key] ||= []; CACHE[key] << ...`). Concurrent
227
+ `set`/`get` from threads is therefore unprotected; the gem's own processes
228
+ are fork-based, not thread-based (see
229
+ [LockingAndConcurrency.md](LockingAndConcurrency.md)).
230
+
231
+ ## Related pages
232
+
233
+ - [Architecture.md](Architecture.md) — where config sits in the layering.
234
+ - [CommandLineOptions.md](../user/CommandLineOptions.md) — the user-facing
235
+ side of `process_config`.
236
+ - [PathResolution.md](PathResolution.md) — `Scout.etc`, `find_all` order,
237
+ `etc/config` search path.
238
+ - [CachingResults.md](../user/CachingResults.md) — what reads `Config.get`.