scout-essentials 1.9.0 → 1.9.3

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +18 -1
  3. data/Rakefile +9 -0
  4. data/VERSION +1 -1
  5. data/doc/Improvements.md +20 -0
  6. data/doc/StartHere.md +7 -4
  7. data/doc/developer/AnnotationSystem.md +10 -15
  8. data/doc/developer/Configuration.md +20 -30
  9. data/doc/developer/CoreUtilities.md +52 -22
  10. data/doc/developer/DesignPrinciples.md +7 -9
  11. data/doc/developer/DocumentationPractices.md +476 -0
  12. data/doc/developer/ErrorHandling.md +11 -20
  13. data/doc/developer/LockingAndConcurrency.md +20 -13
  14. data/doc/developer/StreamingModel.md +13 -14
  15. data/doc/developer/Testing.md +389 -0
  16. data/doc/user/AnnotatingData.md +9 -13
  17. data/doc/user/CommandLineOptions.md +9 -12
  18. data/doc/user/Cookbook.md +7 -10
  19. data/doc/user/HandlingStreams.md +22 -29
  20. data/doc/user/LoggingAndProgress.md +9 -11
  21. data/doc/user/ProducingResources.md +0 -1
  22. data/doc/user/RemoteData.md +10 -13
  23. data/doc/user/RunningCommands.md +12 -17
  24. data/doc/user/WorkingWithFiles.md +10 -2
  25. data/lib/scout/config.rb +1 -0
  26. data/lib/scout/indiferent_hash.rb +8 -2
  27. data/lib/scout/log/fingerprint.rb +1 -1
  28. data/lib/scout/misc/filesystem.rb +2 -2
  29. data/lib/scout/named_array.rb +4 -0
  30. data/lib/scout/open/final.rb +1 -1
  31. data/lib/scout/open/stream.rb +14 -13
  32. data/lib/scout/path/util.rb +1 -1
  33. data/lib/scout/testing/availability.rb +264 -0
  34. data/lib/scout/testing/scout_state.rb +193 -0
  35. data/lib/scout/testing.rb +82 -0
  36. data/research/doc-audit-findings.md +7 -5
  37. data/research/doc-structure.md +89 -0
  38. data/research/lock-namespace-blindness.md +73 -0
  39. data/research/probing-guide.md +311 -0
  40. data/research/testing-availability-scout-ai-handoff.md +235 -0
  41. data/research/testing-availability-scout-rig-handoff.md +355 -0
  42. data/scout-essentials.gemspec +24 -3
  43. data/test/scout/annotation/test_dup_clone.rb +31 -0
  44. data/test/scout/indiferent_hash/test_case_insensitive_staleness.rb +27 -0
  45. data/test/scout/indiferent_hash/test_string2hash_false.rb +13 -0
  46. data/test/scout/misc/test_filesystem.rb +10 -0
  47. data/test/scout/misc/test_timespan.rb +30 -0
  48. data/test/scout/open/test_compression_detection.rb +32 -0
  49. data/test/scout/open/test_forced_gzip_broken.rb +25 -0
  50. data/test/scout/test_named_array_resolution.rb +49 -0
  51. data/test/scout/test_testing.rb +195 -0
  52. data/test/scout/test_tmpfile_cleanup.rb +23 -0
  53. data/test/scout/testing/test_availability.rb +222 -0
  54. data/test/scout/testing/test_state_bindings.rb +163 -0
  55. data/test/test_helper.rb +26 -12
  56. metadata +23 -2
@@ -0,0 +1,264 @@
1
+ # Opt-in generic execution/availability primitives for test suites.
2
+ #
3
+ # Layering contract (see research/testing-availability-*.md in this repo):
4
+ # this module deliberately contains ONLY stdlib-generic primitives — PATH
5
+ # lookup, bounded child execution, and a bounded TCP connect. No interpreter,
6
+ # package, model, or network-service knowledge lives here: those probes
7
+ # belong to the layers that own them.
8
+ #
9
+ # This file is deliberately not required by scout-essentials.
10
+ require 'socket'
11
+
12
+ module Scout
13
+ module Testing
14
+ module Availability
15
+ DEFAULT_TIMEOUT = 30
16
+ TCP_TIMEOUT = 5
17
+ OUTPUT_LIMIT = 65_536
18
+ READ_CHUNK = 16 * 1024
19
+ KILL_GRACE = 0.1
20
+ CHUNK_POLL = 0.05
21
+ TRUNCATION_NOTE = "\n[output truncated]"
22
+
23
+ # Result of a run_command call: everything the caller needs to decide,
24
+ # including the failure modes that are reported rather than raised.
25
+ CommandResult = Struct.new(
26
+ :argv, :success, :exit_status, :stdout, :stderr,
27
+ :timed_out, :launch_error, keyword_init: true
28
+ ) do
29
+ def success?
30
+ !!success
31
+ end
32
+
33
+ # Reason for a non-successful run, preferring the concrete failure
34
+ # mode over raw output. Order: launch failure, timeout, exit status,
35
+ # stderr, stdout.
36
+ def reason
37
+ return nil if success?
38
+ return launch_error if launch_error
39
+ return 'command timed out' if timed_out
40
+ return "exit status #{exit_status}" if exit_status
41
+
42
+ detail = stderr.to_s.strip
43
+ return detail unless detail.empty?
44
+
45
+ detail = stdout.to_s.strip
46
+ return detail unless detail.empty?
47
+
48
+ 'could not start executable'
49
+ end
50
+ end
51
+
52
+ class << self
53
+ # True when name resolves to an executable on PATH, or contains a
54
+ # path separator and names an executable file directly. No shell.
55
+ def executable?(name)
56
+ return false if name.to_s.empty?
57
+
58
+ candidate = name.to_s
59
+ if candidate.include?(File::SEPARATOR) || candidate.include?('\\')
60
+ return File.file?(candidate) && File.executable?(candidate)
61
+ end
62
+
63
+ ENV['PATH'].to_s.split(File::PATH_SEPARATOR).any? do |directory|
64
+ next false if directory.to_s.empty?
65
+
66
+ path = File.join(directory, candidate)
67
+ File.file?(path) && File.executable?(path)
68
+ end
69
+ end
70
+
71
+ # Run argv (never a shell string) with a hard deadline. Both output
72
+ # streams are drained concurrently so a chatty child cannot deadlock
73
+ # a pipe, output is bounded per stream, and a timeout escalates
74
+ # TERM -> KILL for the child process group.
75
+ #
76
+ # Launch failure (e.g. Errno::ENOENT) and timeout are reported on the
77
+ # returned CommandResult, never raised. ArgumentError is raised only
78
+ # for a non-positive/non-finite timeout or an empty argv, which are
79
+ # programming errors. Not memoized: the answer can change per call.
80
+ def run_command(argv, timeout: DEFAULT_TIMEOUT)
81
+ argv = Array(argv).map(&:to_s)
82
+ raise ArgumentError, 'argv must not be empty' if argv.empty?
83
+
84
+ seconds = checked_timeout(timeout)
85
+
86
+ out_read, out_write = IO.pipe
87
+ err_read, err_write = IO.pipe
88
+ pid = nil
89
+ reaped = false
90
+ killed = false
91
+ readers = [out_read, err_read]
92
+ stdout = +''
93
+ stderr = +''
94
+ status = nil
95
+
96
+ begin
97
+ pid = Process.spawn(*argv, out: out_write, err: err_write,
98
+ pgroup: true, close_others: true)
99
+ rescue StandardError => error
100
+ close_all(readers + [out_write, err_write])
101
+ return CommandResult.new(
102
+ argv: argv, success: false, exit_status: nil,
103
+ stdout: '', stderr: '', timed_out: false,
104
+ launch_error: "#{error.class}: #{error.message}"
105
+ )
106
+ ensure
107
+ # Closed in the parent regardless of spawn outcome, so EOF on the
108
+ # read ends is meaningful.
109
+ [out_write, err_write].each { |io| io.close unless io.closed? }
110
+ end
111
+
112
+ timed_out = false
113
+ deadline = monotonic + seconds
114
+
115
+ # Drain both pipes while waiting, so neither fills up and blocks
116
+ # the child while this side is blocked on the other stream.
117
+ until status && readers.empty?
118
+ remaining = deadline - monotonic
119
+ if remaining <= 0
120
+ timed_out = true
121
+ break
122
+ end
123
+
124
+ ready, = IO.select(readers, nil, nil, [remaining, CHUNK_POLL].min)
125
+ Array(ready).each do |io|
126
+ begin
127
+ chunk = io.read_nonblock(READ_CHUNK)
128
+ append_limited(io.equal?(out_read) ? stdout : stderr, chunk)
129
+ rescue EOFError
130
+ readers.delete(io)
131
+ io.close
132
+ end
133
+ end
134
+
135
+ next if status
136
+
137
+ waited, candidate = Process.waitpid2(pid, Process::WNOHANG)
138
+ if waited
139
+ status = candidate
140
+ reaped = true
141
+ end
142
+ end
143
+
144
+ if timed_out
145
+ killed = true
146
+ status ||= terminate_group(pid)
147
+ reaped = true
148
+ stderr = note(stderr, "command timed out after #{seconds}s")
149
+ end
150
+
151
+ close_all(readers)
152
+
153
+ CommandResult.new(
154
+ argv: argv,
155
+ success: !timed_out && status&.exitstatus == 0,
156
+ exit_status: status&.exitstatus,
157
+ stdout: stdout,
158
+ stderr: stderr,
159
+ timed_out: timed_out,
160
+ launch_error: nil
161
+ )
162
+ ensure
163
+ close_all(readers) if readers
164
+ unless pid.nil? || reaped
165
+ terminate_group(pid) unless killed
166
+ reap(pid)
167
+ end
168
+ end
169
+
170
+ # True means only that a TCP connection to host:port was established
171
+ # within the timeout. A malformed port (non-numeric string, out of
172
+ # range) reports false rather than raising; only an invalid timeout
173
+ # raises, since that is a programming error. No memoization:
174
+ # reachability can change.
175
+ def tcp_reachable?(host, port, timeout: TCP_TIMEOUT)
176
+ seconds = checked_timeout(timeout)
177
+ port = Integer(port) rescue nil
178
+ return false unless (1..65_535).cover?(port) && !host.to_s.empty?
179
+
180
+ begin
181
+ Socket.tcp(host.to_s, port, connect_timeout: seconds) do |socket|
182
+ socket.close
183
+ end
184
+ true
185
+ rescue SocketError, SystemCallError, IOError, ArgumentError
186
+ false
187
+ end
188
+ end
189
+
190
+ private
191
+
192
+ def monotonic
193
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
194
+ end
195
+
196
+ def checked_timeout(value)
197
+ number = Float(value)
198
+ unless number.positive? && number.finite?
199
+ raise ArgumentError, 'timeout must be positive and finite'
200
+ end
201
+
202
+ number
203
+ end
204
+
205
+ def close_all(ios)
206
+ ios.each { |io| io.close unless io.closed? }
207
+ rescue IOError
208
+ nil
209
+ end
210
+
211
+ # TERM the process group, wait a short grace period, then KILL.
212
+ # Returns the exit status when this call reaped the child, nil when
213
+ # the child was already reaped elsewhere or had to be killed.
214
+ def terminate_group(pid)
215
+ signal_group(pid, 'TERM')
216
+ grace = monotonic + KILL_GRACE
217
+ loop do
218
+ waited, status = waitpid_nohang(pid)
219
+ return status if waited
220
+ break if monotonic >= grace
221
+
222
+ sleep(CHUNK_POLL / 2)
223
+ end
224
+ signal_group(pid, 'KILL')
225
+ nil
226
+ end
227
+
228
+ def waitpid_nohang(pid)
229
+ Process.waitpid2(pid, Process::WNOHANG)
230
+ rescue Errno::ECHILD
231
+ [false, nil]
232
+ end
233
+
234
+ def signal_group(pid, signal)
235
+ Process.kill(signal, -pid)
236
+ rescue SystemCallError
237
+ begin
238
+ Process.kill(signal, pid)
239
+ rescue SystemCallError
240
+ nil
241
+ end
242
+ end
243
+
244
+ def reap(pid)
245
+ waited, status = Process.waitpid2(pid)
246
+ waited ? status : nil
247
+ rescue Errno::ECHILD
248
+ nil
249
+ end
250
+
251
+ def append_limited(buffer, chunk)
252
+ remaining = OUTPUT_LIMIT - buffer.bytesize
253
+ buffer << chunk.byteslice(0, remaining) if remaining.positive?
254
+ marker = chunk.bytesize > remaining && !buffer.end_with?(TRUNCATION_NOTE)
255
+ buffer << TRUNCATION_NOTE if marker
256
+ end
257
+
258
+ def note(text, addition)
259
+ text.empty? ? addition : "#{text}\n#{addition}"
260
+ end
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,193 @@
1
+ # Opt-in Scout global-state isolation for tests.
2
+ require_relative '../testing'
3
+ require_relative '../tmpfile'
4
+ require_relative '../persist'
5
+ require_relative '../open'
6
+
7
+ module Scout
8
+ module Testing
9
+ class State
10
+ class RestoreError < StandardError
11
+ attr_reader :errors, :original_error
12
+
13
+ def initialize(errors, original_error: nil)
14
+ @errors = errors
15
+ @original_error = original_error
16
+ details = errors.map { |error| "#{error.class}: #{error.message}" }.join('; ')
17
+ message = "Failed to restore #{errors.length} state binding(s): #{details}"
18
+ message += " (while handling #{original_error.class}: #{original_error.message})" if original_error
19
+ super(message)
20
+ end
21
+ end
22
+
23
+ def initialize(root, bindings: [])
24
+ @root = File.expand_path(root)
25
+ @bindings = normalize_bindings(bindings)
26
+ end
27
+
28
+ def activate
29
+ raise RuntimeError, 'State is already active' if @saved
30
+
31
+ @saved = {}
32
+ @activated_bindings = []
33
+ begin
34
+ [[TmpFile, :@tmpdir], [Persist, :@cache_dir], [Persist, :@lock_dir],
35
+ [Open, :@remote_cache_dir], [Open, :@sensible_write_dir],
36
+ [Open, :@sensible_write_lock_dir]].each do |owner, ivar|
37
+ @saved[[owner, ivar]] = [owner.instance_variable_defined?(ivar), owner.instance_variable_get(ivar)]
38
+ end
39
+ @binding_snapshots = @bindings.to_h do |binding|
40
+ [binding, [binding[:owner].instance_variable_defined?(binding[:ivar]),
41
+ binding[:owner].instance_variable_get(binding[:ivar])]]
42
+ end
43
+ @memory_cache = Persist::MEMORY_CACHE.dup
44
+
45
+ FileUtils.mkdir_p(@root)
46
+ @root = File.realpath(@root)
47
+ TmpFile.tmpdir = File.join(@root, 'tmpfiles')
48
+ Persist.cache_dir = File.join(@root, 'persistence-cache')
49
+ Persist.lock_dir = File.join(@root, 'persistence-locks')
50
+ Open.remote_cache_dir = File.join(@root, 'remote-cache')
51
+ Open.sensible_write_dir = File.join(@root, 'sensible-write')
52
+ Open.sensible_write_lock_dir = File.join(@root, 'sensible-write-locks')
53
+ @bindings.each do |binding|
54
+ binding[:target] = File.expand_path(binding[:relative_path], @root)
55
+ create_binding_directory(binding[:target])
56
+ @activated_bindings << binding
57
+ binding[:owner].public_send(binding[:writer], binding[:target])
58
+ end
59
+ Persist::MEMORY_CACHE.clear
60
+ self
61
+ rescue Exception => activation_error
62
+ begin
63
+ restore
64
+ rescue State::RestoreError => restore_error
65
+ raise State::RestoreError.new(restore_error.errors, original_error: activation_error)
66
+ end
67
+ raise
68
+ end
69
+ end
70
+
71
+ def restore
72
+ return self unless @saved
73
+
74
+ errors = []
75
+ @activated_bindings.reverse_each do |binding|
76
+ was_defined, value = @binding_snapshots.fetch(binding)
77
+ begin
78
+ # Accessor-backed bindings are intended for simple settings whose
79
+ # backing instance variable is the conventional @<accessor>.
80
+ binding[:owner].public_send(binding[:writer], value) if was_defined
81
+ rescue Exception => error
82
+ errors << error
83
+ ensure
84
+ begin
85
+ if was_defined
86
+ binding[:owner].instance_variable_set(binding[:ivar], value)
87
+ elsif binding[:owner].instance_variable_defined?(binding[:ivar])
88
+ binding[:owner].remove_instance_variable(binding[:ivar])
89
+ end
90
+ rescue Exception => error
91
+ errors << error
92
+ end
93
+ end
94
+ end
95
+
96
+ @saved.each do |(owner, ivar), (was_defined, value)|
97
+ begin
98
+ if was_defined
99
+ owner.instance_variable_set(ivar, value)
100
+ elsif owner.instance_variable_defined?(ivar)
101
+ owner.remove_instance_variable(ivar)
102
+ end
103
+ rescue Exception => error
104
+ errors << error
105
+ end
106
+ end
107
+ begin
108
+ Persist::MEMORY_CACHE.replace(@memory_cache) if @memory_cache
109
+ rescue Exception => error
110
+ errors << error
111
+ ensure
112
+ @saved = nil
113
+ @binding_snapshots = nil
114
+ @activated_bindings = nil
115
+ @memory_cache = nil
116
+ end
117
+
118
+ raise RestoreError, errors unless errors.empty?
119
+ self
120
+ end
121
+
122
+ private
123
+
124
+ def create_binding_directory(target)
125
+ prefix = "#{@root}#{File::SEPARATOR}"
126
+ raise ArgumentError, "binding path must stay under state root: #{target}" unless target.start_with?(prefix)
127
+
128
+ relative = target.delete_prefix(prefix)
129
+ current = @root
130
+ relative.split(File::SEPARATOR).each do |part|
131
+ current = File.join(current, part)
132
+ raise ArgumentError, "binding path traverses a symlink: #{current}" if File.symlink?(current)
133
+
134
+ begin
135
+ Dir.mkdir(current)
136
+ rescue Errno::EEXIST
137
+ raise unless File.directory?(current) && !File.symlink?(current)
138
+ end
139
+ raise ArgumentError, "binding path is not a directory: #{current}" unless File.directory?(current) && !File.symlink?(current)
140
+ end
141
+ end
142
+
143
+ def normalize_bindings(bindings)
144
+ raise ArgumentError, 'bindings must be an Array' unless Array === bindings
145
+
146
+ seen = {}
147
+ bindings.map do |binding|
148
+ unless Array === binding && binding.length == 3
149
+ raise ArgumentError, 'each binding must be [owner, accessor, relative_path]'
150
+ end
151
+ owner, accessor, relative_path = binding
152
+ unless owner.respond_to?(:instance_variable_defined?) && Symbol === accessor &&
153
+ accessor.to_s.match?(/\A[a-zA-Z_]\w*\z/)
154
+ raise ArgumentError, "invalid state binding owner or accessor: #{binding.inspect}"
155
+ end
156
+ writer = "#{accessor}=".to_sym
157
+ raise ArgumentError, "#{owner} does not respond to #{writer}" unless owner.respond_to?(writer)
158
+ unless String === relative_path && !relative_path.empty? && !relative_path.start_with?(File::SEPARATOR)
159
+ raise ArgumentError, "binding path must be a non-empty relative path: #{relative_path.inspect}"
160
+ end
161
+ target = File.expand_path(relative_path, @root)
162
+ unless target.start_with?(@root.end_with?(File::SEPARATOR) ? @root : "#{@root}#{File::SEPARATOR}")
163
+ raise ArgumentError, "binding path must stay under state root: #{relative_path.inspect}"
164
+ end
165
+ key = [owner, accessor]
166
+ raise ArgumentError, "duplicate state binding: #{owner}.#{accessor}" if seen[key]
167
+ seen[key] = true
168
+ { owner: owner, accessor: accessor, writer: writer,
169
+ ivar: "@#{accessor}".to_sym, target: target, relative_path: relative_path }
170
+ end
171
+ end
172
+ end
173
+
174
+ module_function
175
+
176
+ def with_isolated_state(root, bindings: [])
177
+ state = State.new(root, bindings: bindings)
178
+ begin
179
+ state.activate
180
+ yield state
181
+ rescue Exception => original_error
182
+ begin
183
+ state.restore
184
+ rescue State::RestoreError => restore_error
185
+ raise State::RestoreError.new(restore_error.errors, original_error: original_error)
186
+ end
187
+ raise
188
+ else
189
+ state.restore
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,82 @@
1
+ # Opt-in support for generic test helpers; deliberately not required by scout-essentials.
2
+ require 'tmpdir'
3
+ require 'fileutils'
4
+
5
+ module Scout
6
+ module Testing
7
+ module Assertions
8
+ def assert_equal_path(expected, actual, message = nil)
9
+ assert_equal File.expand_path(expected), File.expand_path(actual), message
10
+ end
11
+
12
+ def assert_path_exists(path, message = nil)
13
+ assert File.exist?(path), message || "Expected path to exist: #{path}"
14
+ end
15
+ end
16
+
17
+ module_function
18
+
19
+ def create_tmpdir(parent: Dir.tmpdir, prefix: 'scout-test-')
20
+ FileUtils.mkdir_p(parent)
21
+ Dir.mktmpdir(prefix, parent)
22
+ end
23
+
24
+ def remove_tmpdir(path)
25
+ FileUtils.remove_entry(path) if path && File.exist?(path)
26
+ end
27
+
28
+ def with_tmpdir(parent: Dir.tmpdir, prefix: 'scout-test-')
29
+ path = create_tmpdir(parent: parent, prefix: prefix)
30
+ begin
31
+ yield path
32
+ ensure
33
+ remove_tmpdir(path)
34
+ end
35
+ end
36
+
37
+ # Resolve against the consumer's repository, not this helper's source tree.
38
+ def fixture_root(from:, directory: 'test/fixtures')
39
+ File.expand_path(directory, repository_root(from))
40
+ end
41
+
42
+ def fixture_path(*parts, from:, directory: 'test/fixtures')
43
+ File.join(fixture_root(from: from, directory: directory), *parts)
44
+ end
45
+
46
+ def repository_root(from)
47
+ start = File.expand_path(from)
48
+ start = File.dirname(start) unless File.directory?(start)
49
+ ancestors = []
50
+ path = start
51
+ loop do
52
+ ancestors << path
53
+ parent = File.dirname(path)
54
+ break if parent == path
55
+ path = parent
56
+ end
57
+
58
+ # A Git worktree is authoritative when present, including for paths
59
+ # nested below a consumer's test or lib directory.
60
+ git_root = ancestors.find do |candidate|
61
+ File.directory?(File.join(candidate, '.git')) || File.file?(File.join(candidate, '.git'))
62
+ end
63
+ return git_root if git_root
64
+
65
+ # For non-Git consumers, infer the root from the conventional test/ or
66
+ # lib/ layout surrounding the supplied file/directory. This is a
67
+ # heuristic, not a universal repository detector: arbitrary paths with
68
+ # no such marker retain the conservative file-local fallback below.
69
+ layout_directory = ancestors.find { |candidate| %w[test lib].include?(File.basename(candidate)) }
70
+ return File.dirname(layout_directory) if layout_directory
71
+
72
+ # Also support a consumer root passed directly (or a directory beneath
73
+ # it) when that root contains a conventional top-level test/ or lib/.
74
+ root_with_layout = ancestors.find do |candidate|
75
+ File.directory?(File.join(candidate, 'test')) || File.directory?(File.join(candidate, 'lib'))
76
+ end
77
+ return root_with_layout if root_with_layout
78
+
79
+ File.dirname(File.expand_path(from))
80
+ end
81
+ end
82
+ end
@@ -3,11 +3,13 @@
3
3
  Audit target: `/bulk/mvazque2/git/scout-essentials` (gem v1.8.8, HEAD `a0324a4`).
4
4
  Outcome: `doc/` fully rewritten (17 pages) + 5 new pages + `README.md` rewritten;
5
5
  Critic final gate: **ACCEPT_WITH_NOTES** (notes applied).
6
- This file is the retained summary. The full evidence trail (coverage matrix with
7
- 261 rows, 22 per-claim ledgers, 3 implementation inventories, 66 behavior probes,
8
- ecosystem attribution, phase reports and critic gates) lives in
9
- `research/doc_audit/`, `research/` and `tmp/critic/` — see
10
- `research/doc_audit/resumption-reference.md` §2 for the index.
6
+ This file is the retained summary. Of the full evidence trail, the
7
+ durable parts still in the repository are
8
+ `research/behavior-probes.md` (66 behavior probes, executed
9
+ transcripts) and the three `research/implementation-inventory-*.md`
10
+ file:line inventories; the transient working directories
11
+ (`research/doc_audit/`, `tmp/critic/`) were removed after the audit
12
+ closed.
11
13
 
12
14
  Disposition counts (coverage-matrix sections A–F, 238 rows): OK 3 · FIX 117 ·
13
15
  EXPAND 67 · NEW_PAGE 27 · INTERNAL 6 · ELSEWHERE 13 · OUT_OF_SCOPE 6.
@@ -0,0 +1,89 @@
1
+ # Documentation review — how `doc/` and `research/` are organized
2
+
3
+ **Status:** Non-normative process artifact.
4
+
5
+ This note records how the scout-essentials documentation set is
6
+ structured after the 2026-09 review, and the standard each layer holds
7
+ itself to. It is for future maintainers deciding where new material
8
+ belongs.
9
+
10
+ ## The two-layer contract
11
+
12
+ - **`doc/`** — normative, well-proven, core concepts only. A claim
13
+ belongs here once it is stable, verified against the current `lib/`,
14
+ and something a user or developer needs to operate the library. Pages
15
+ cite `file:line` anchors rather than probes or transcripts: the source
16
+ itself is the citation.
17
+ - **`research/`** — non-normative investigations, process records, and
18
+ claims that are interesting but either not core, not fully proven, or
19
+ machine/lesson specific. Every file declares
20
+ `**Status:** Non-normative investigation artifact. May be outdated.`
21
+
22
+ ## Layout
23
+
24
+ ```
25
+ doc/
26
+ StartHere.md — the entry point + reading paths
27
+ Improvements.md — running log of known defects / improvement ideas
28
+ user/ — how to *use* each subsystem, task-oriented
29
+ WorkingWithFiles, RemoteData, RunningCommands, HandlingStreams,
30
+ CachingResults, ProducingResources, AnnotatingData,
31
+ LoggingAndProgress, CommandLineOptions, Cookbook
32
+ (Path itself is covered by developer/PathResolution.md and used
33
+ throughout the user pages)
34
+ developer/ — how each subsystem *works*, mechanism-oriented
35
+ Architecture, DesignPrinciples, AnnotationSystem, PathResolution,
36
+ PersistenceAndResources, StreamingModel, Configuration,
37
+ ErrorHandling, LockingAndConcurrency, CoreUtilities
38
+ research/
39
+ *-analysis.md — per-chunk behavioural analyses (annotations,
40
+ commands/streaming, io/paths, design philosophy)
41
+ implementation-inventory-*.md — file:line inventories per chunk
42
+ doc-audit-findings.md — forensic audit report (hallucinations found,
43
+ corrected claims, decisions taken)
44
+ ecosystem-attribution.md — which repo owns which constant
45
+ behavior-probes.md — historical probe transcripts (the probes
46
+ themselves were promoted to tests / deleted)
47
+ lock-namespace-blindness.md — promoted-from-cortex behavioural hazard
48
+ ```
49
+
50
+ ## Where new material goes
51
+
52
+ | kind of finding | destination |
53
+ |---|---|
54
+ | core, verified behaviour a user needs | `doc/user/<Topic>.md` |
55
+ | core, verified mechanism a maintainer needs | `doc/developer/<Topic>.md` |
56
+ | known defect / improvement candidate | `doc/Improvements.md` (numbered `I#`) |
57
+ | hazard, heuristic, environment-specific behaviour | `research/<topic>.md` |
58
+ | one-off verification transcript | nowhere — write a regression test instead |
59
+ | cross-repo attribution of a constant | `research/ecosystem-attribution.md` |
60
+
61
+ ## The promotion path (2026-09 episode)
62
+
63
+ The 2026-09 documentation review followed this ladder:
64
+
65
+ ```
66
+ probes (cortex probe/*.rb artifacts)
67
+ → claims (cortex *.md artifacts)
68
+ → doc/ pages (claims rewritten pedagogically, probe citations removed)
69
+ → tests (selected probes promoted to test/scout/**/test_*.rb)
70
+ → cortex probes + execution records deleted
71
+ ```
72
+
73
+ Rules applied:
74
+
75
+ 1. **No probe references in `doc/`.** Documentation cites `file:line`
76
+ anchors and regression tests; transcripts are process, not product.
77
+ 2. **Probes whose claims were promoted became tests, not corpses.**
78
+ Eight regression tests were extracted (NamedArray resolution,
79
+ CaseInsensitiveHash staleness, `string2hash` false-asymmetry,
80
+ `Misc.timespan` tokens, Open compression detection, broken
81
+ `:gzip => true`, annotation dup/clone, TmpFile cleanup).
82
+ 3. **Probes with no durable claim were deleted**, together with their
83
+ `properties/Observation/probe` execution records. The four promoted
84
+ claims artifacts stay in cortex with a "promoted out of cortex"
85
+ banner naming their new home.
86
+ 4. **Cortex claims that document a hazard stay in cortex** only while
87
+ unproven; `persist_lock_theft_across_agents` was promoted to
88
+ `research/lock-namespace-blindness.md` with the operational advice
89
+ folded into `doc/developer/LockingAndConcurrency.md`.