scout-essentials 1.9.1 → 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.
- checksums.yaml +4 -4
- data/.vimproject +18 -1
- data/Rakefile +9 -0
- data/VERSION +1 -1
- data/doc/Improvements.md +20 -0
- data/doc/StartHere.md +7 -4
- data/doc/developer/AnnotationSystem.md +10 -15
- data/doc/developer/Configuration.md +20 -30
- data/doc/developer/CoreUtilities.md +52 -22
- data/doc/developer/DesignPrinciples.md +7 -9
- data/doc/developer/DocumentationPractices.md +476 -0
- data/doc/developer/ErrorHandling.md +11 -20
- data/doc/developer/LockingAndConcurrency.md +20 -13
- data/doc/developer/StreamingModel.md +13 -14
- data/doc/developer/Testing.md +389 -0
- data/doc/user/AnnotatingData.md +9 -13
- data/doc/user/CommandLineOptions.md +9 -12
- data/doc/user/Cookbook.md +7 -10
- data/doc/user/HandlingStreams.md +22 -29
- data/doc/user/LoggingAndProgress.md +9 -11
- data/doc/user/ProducingResources.md +0 -1
- data/doc/user/RemoteData.md +10 -13
- data/doc/user/RunningCommands.md +12 -17
- data/doc/user/WorkingWithFiles.md +10 -2
- data/lib/scout/config.rb +1 -0
- data/lib/scout/indiferent_hash.rb +8 -2
- data/lib/scout/log/fingerprint.rb +1 -1
- data/lib/scout/named_array.rb +4 -0
- data/lib/scout/open/final.rb +1 -1
- data/lib/scout/open/stream.rb +14 -13
- data/lib/scout/path/util.rb +1 -1
- data/lib/scout/testing/availability.rb +264 -0
- data/lib/scout/testing/scout_state.rb +193 -0
- data/lib/scout/testing.rb +82 -0
- data/research/doc-audit-findings.md +7 -5
- data/research/doc-structure.md +89 -0
- data/research/lock-namespace-blindness.md +73 -0
- data/research/probing-guide.md +311 -0
- data/research/testing-availability-scout-ai-handoff.md +235 -0
- data/research/testing-availability-scout-rig-handoff.md +355 -0
- data/scout-essentials.gemspec +24 -3
- data/test/scout/annotation/test_dup_clone.rb +31 -0
- data/test/scout/indiferent_hash/test_case_insensitive_staleness.rb +27 -0
- data/test/scout/indiferent_hash/test_string2hash_false.rb +13 -0
- data/test/scout/misc/test_timespan.rb +30 -0
- data/test/scout/open/test_compression_detection.rb +32 -0
- data/test/scout/open/test_forced_gzip_broken.rb +25 -0
- data/test/scout/test_named_array_resolution.rb +49 -0
- data/test/scout/test_testing.rb +195 -0
- data/test/scout/test_tmpfile_cleanup.rb +23 -0
- data/test/scout/testing/test_availability.rb +222 -0
- data/test/scout/testing/test_state_bindings.rb +163 -0
- data/test/test_helper.rb +26 -12
- 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.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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`.
|