agent-session_context 0.1.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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +268 -0
  5. data/exe/agent-session-context +6 -0
  6. data/lib/agent/session_context/builder.rb +146 -0
  7. data/lib/agent/session_context/cli/options.rb +213 -0
  8. data/lib/agent/session_context/cli.rb +229 -0
  9. data/lib/agent/session_context/config.rb +206 -0
  10. data/lib/agent/session_context/errors.rb +15 -0
  11. data/lib/agent/session_context/evidence_collector.rb +227 -0
  12. data/lib/agent/session_context/evidence_packet.rb +271 -0
  13. data/lib/agent/session_context/immutable_value.rb +71 -0
  14. data/lib/agent/session_context/injected_context.rb +92 -0
  15. data/lib/agent/session_context/injected_context_collector.rb +53 -0
  16. data/lib/agent/session_context/item.rb +54 -0
  17. data/lib/agent/session_context/prompt.rb +40 -0
  18. data/lib/agent/session_context/prompt_extractor.rb +31 -0
  19. data/lib/agent/session_context/renderers/human_display.rb +113 -0
  20. data/lib/agent/session_context/renderers/json.rb +13 -0
  21. data/lib/agent/session_context/renderers/json_lines.rb +13 -0
  22. data/lib/agent/session_context/renderers/markdown.rb +126 -0
  23. data/lib/agent/session_context/renderers/serializer.rb +124 -0
  24. data/lib/agent/session_context/renderers/text.rb +122 -0
  25. data/lib/agent/session_context/semantic_categories.rb +89 -0
  26. data/lib/agent/session_context/semantic_pipeline.rb +151 -0
  27. data/lib/agent/session_context/semantic_schema.rb +75 -0
  28. data/lib/agent/session_context/session_resolver.rb +147 -0
  29. data/lib/agent/session_context/snapshot.rb +128 -0
  30. data/lib/agent/session_context/source_ref.rb +46 -0
  31. data/lib/agent/session_context/subprocess_runner.rb +362 -0
  32. data/lib/agent/session_context/summarizers/claude.rb +126 -0
  33. data/lib/agent/session_context/summarizers/codex.rb +132 -0
  34. data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
  35. data/lib/agent/session_context/summarizers.rb +35 -0
  36. data/lib/agent/session_context/summary_parser.rb +219 -0
  37. data/lib/agent/session_context/transcript.rb +236 -0
  38. data/lib/agent/session_context/version.rb +7 -0
  39. data/lib/agent/session_context.rb +56 -0
  40. metadata +112 -0
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Agent
6
+ module SessionContext
7
+ class CLI
8
+ FORMATS = %w[text markdown json jsonl].freeze
9
+
10
+ def initialize(
11
+ argv,
12
+ env: ENV,
13
+ stdout: $stdout,
14
+ stderr: $stderr,
15
+ now: Time.now,
16
+ resolver: SessionResolver.new(env: env),
17
+ builder: Builder.new(now: now),
18
+ backend_factory: Summarizers,
19
+ config_loader: Config
20
+ )
21
+ @argv = argv.dup
22
+ @env = env
23
+ @stdout = stdout
24
+ @stderr = stderr
25
+ @resolver = resolver
26
+ @builder = builder
27
+ @backend_factory = backend_factory
28
+ @config_loader = config_loader
29
+ @current_format = :text
30
+ end
31
+
32
+ def run
33
+ case (command = @argv.shift)
34
+ when "show" then show
35
+ when "prompts" then prompts
36
+ when "summarize" then summarize
37
+ when "version", "--version", "-v" then version(@argv)
38
+ when nil then help([], @stdout, 0)
39
+ when "help", "--help", "-h" then help(@argv, @stdout, 0)
40
+ else
41
+ @stderr.puts "unknown command: #{safe_text(command)}"
42
+ 1
43
+ end
44
+ rescue Agent::SessionContext::Error, OptionParser::ParseError => e
45
+ emit_error(e)
46
+ 1
47
+ end
48
+
49
+ private
50
+
51
+ def show
52
+ @current_format = Options.hinted_format(@argv)
53
+ selection = Options.parse(@argv, command: :show)
54
+ session = resolve_session(selection)
55
+ include_injected = selection.fetch(:include_injected)
56
+ snapshot = @builder.show(session, include_injected:)
57
+ if include_injected
58
+ @stderr.puts "warning: exact prompts and full injected context may contain secrets; review before sharing"
59
+ else
60
+ @stderr.puts "warning: exact prompts may contain secrets; review before sharing"
61
+ end
62
+ emit_warnings(session.uid, snapshot.warnings)
63
+ write_output(snapshot, format: selection.fetch(:format))
64
+ partial_capture_status(snapshot)
65
+ end
66
+
67
+ def prompts
68
+ @current_format = Options.hinted_format(@argv)
69
+ selection = Options.parse(@argv, command: :prompts)
70
+ session = resolve_session(selection)
71
+ prompts_result = @builder.prompts_result(session)
72
+ @stderr.puts "warning: exact prompts may contain secrets; review before sharing"
73
+ emit_warnings(session.uid, prompts_result.reader_warnings)
74
+ write_output(prompts_result.prompts, format: selection.fetch(:format))
75
+ prompts_result.partial_capture? ? 1 : 0
76
+ end
77
+
78
+ def summarize
79
+ @current_format = Options.hinted_format(@argv)
80
+ selection = Options.parse(@argv, command: :summarize)
81
+ session = resolve_session(selection)
82
+ config = @config_loader.load(session:, env: @env, timeout: selection.fetch(:timeout))
83
+ backend_name = selection.fetch(:using) == :auto ? session.agent : selection.fetch(:using)
84
+ timeout_seconds = configured_timeout_seconds(config)
85
+ @stderr.puts "summarizing with #{safe_text(backend_name)} (timeout: #{format_timeout(timeout_seconds)}s)"
86
+ summarizer = @backend_factory.for(backend_name, timeout_seconds:)
87
+ snapshot = @builder.summarize(session, summarizer:)
88
+ emit_warnings(session.uid, snapshot.warnings)
89
+ write_output(snapshot, format: selection.fetch(:format))
90
+ partial_capture_status(snapshot)
91
+ end
92
+
93
+ def version(arguments)
94
+ Options.parse_no_args(arguments, command: :version)
95
+ @stdout.puts Agent::SessionContext::VERSION
96
+ 0
97
+ end
98
+
99
+ def help(arguments, io, status)
100
+ Options.parse_no_args(arguments, command: :help)
101
+ io.puts <<~HELP
102
+ Usage: agent-session-context COMMAND [options] [SESSION]
103
+
104
+ Commands:
105
+ show
106
+ prompts
107
+ summarize
108
+ version
109
+ help
110
+
111
+ Common options:
112
+ --current Use environment identity, else latest on disk
113
+ --agent claude|codex Narrow explicit lookup or --current disk fallback
114
+ --format text|markdown|json|jsonl
115
+
116
+ Show behavior:
117
+ Includes exact user prompts and an injected-context inventory.
118
+ --include-injected Include deduplicated full injected text
119
+ Excludes assistant messages, thinking, tool-result bodies,
120
+ and raw provider envelopes.
121
+
122
+ Summarize options:
123
+ --using auto|claude|codex
124
+ --timeout SECONDS Set a 1-3600s timeout for each provider call
125
+ HELP
126
+ status
127
+ end
128
+
129
+ def resolve_session(selection)
130
+ if selection.fetch(:current)
131
+ @resolver.current(agent: selection.fetch(:agent)) do |session|
132
+ @stderr.puts(
133
+ "warning: --current found no session environment identifier; " \
134
+ "using latest session on disk: #{safe_text(session.uid)}"
135
+ )
136
+ end
137
+ else
138
+ @resolver.resolve(selection.fetch(:identifier), agent: selection.fetch(:agent))
139
+ end
140
+ end
141
+
142
+ def emit_warnings(session_uid, warnings)
143
+ Array(warnings).each do |warning|
144
+ @stderr.puts "warning: #{safe_text(session_uid)}: #{safe_text(warning)}"
145
+ end
146
+ end
147
+
148
+ def write_output(value, format:)
149
+ rendered = renderer_for(format).call(value)
150
+ if human_format?(format)
151
+ @stdout.write(rendered)
152
+ @stdout.write("\n")
153
+ else
154
+ @stdout.write(rendered)
155
+ end
156
+ end
157
+
158
+ def renderer_for(format)
159
+ case format
160
+ when :text then Renderers::Text.new
161
+ when :markdown then Renderers::Markdown.new
162
+ when :json then Renderers::JSON.new
163
+ when :jsonl then Renderers::JSONLines.new
164
+ else
165
+ raise OptionParser::ParseError, "unsupported format #{format.inspect}"
166
+ end
167
+ end
168
+
169
+ def human_format?(format)
170
+ %i[text markdown].include?(format)
171
+ end
172
+
173
+ def emit_error(error)
174
+ if @current_format == :json
175
+ error_json = JSON.generate(
176
+ {
177
+ error: {
178
+ type: scrubbed_error_type(error),
179
+ message: Renderers::Serializer.scrub_string(normalized_error_message(error))
180
+ }
181
+ }
182
+ )
183
+ @stdout.write(error_json)
184
+ else
185
+ @stderr.puts safe_text(normalized_error_message(error))
186
+ end
187
+ end
188
+
189
+ def safe_text(value)
190
+ Renderers::HumanDisplay.text_inline(value)
191
+ end
192
+
193
+ def partial_capture_status(snapshot)
194
+ snapshot.summary_metadata.fetch(:reader_warning_count, 0).positive? ? 1 : 0
195
+ end
196
+
197
+ def configured_timeout_seconds(config)
198
+ value = config.timeout_seconds
199
+ if value.is_a?(Integer) && value >= Config::MIN_TIMEOUT_SECONDS && value <= Config::MAX_TIMEOUT_SECONDS
200
+ return value
201
+ end
202
+ if value.is_a?(Float) &&
203
+ value.finite? &&
204
+ value >= Config::MIN_TIMEOUT_SECONDS &&
205
+ value <= Config::MAX_TIMEOUT_SECONDS
206
+ return value
207
+ end
208
+
209
+ raise ConfigurationError, "Invalid configured timeout_seconds."
210
+ end
211
+
212
+ def format_timeout(value)
213
+ return value.to_i.to_s if value.is_a?(Float) && value.finite? && value == value.to_i
214
+
215
+ value.to_s
216
+ end
217
+
218
+ def normalized_error_message(error)
219
+ return error.args.first.to_s if error.instance_of?(OptionParser::ParseError)
220
+
221
+ error.message.to_s
222
+ end
223
+
224
+ def scrubbed_error_type(error)
225
+ Renderers::Serializer.scrub_string(error.class.name.to_s)
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "psych"
5
+
6
+ module Agent
7
+ module SessionContext
8
+ class Config < Data.define(:timeout_seconds)
9
+ DEFAULT_TIMEOUT_SECONDS = 300
10
+ MIN_TIMEOUT_SECONDS = 1
11
+ MAX_TIMEOUT_SECONDS = 3600
12
+ ABSENT = Object.new.freeze
13
+ PROJECT_CONFIG_FILENAME = ".agent-context.yml"
14
+ USER_CONFIG_PATH_SEGMENTS = [".config", "agent_context", "config.yml"].freeze
15
+ private_constant :ABSENT, :PROJECT_CONFIG_FILENAME, :USER_CONFIG_PATH_SEGMENTS
16
+
17
+ class << self
18
+ def load(session:, env: ENV, timeout: nil)
19
+ project_timeout = load_layer(project_source(session))
20
+ user_timeout = load_layer(user_source(env))
21
+
22
+ effective_timeout =
23
+ if timeout.nil?
24
+ if project_timeout.nil?
25
+ user_timeout.nil? ? DEFAULT_TIMEOUT_SECONDS : user_timeout
26
+ else
27
+ project_timeout
28
+ end
29
+ else
30
+ validate_timeout!(timeout, source: "timeout")
31
+ end
32
+
33
+ new(timeout_seconds: effective_timeout)
34
+ end
35
+
36
+ private
37
+
38
+ def load_layer(source)
39
+ return nil unless source
40
+
41
+ path = source.fetch(:path)
42
+ stat = stat_config_path(path, source)
43
+ return nil unless stat
44
+
45
+ raise_configuration_error(source, "must be a regular file") unless stat.file?
46
+
47
+ content = read_utf8(path, stat, source)
48
+ raw = parse_yaml(content, source)
49
+ timeout = extract_timeout(raw, source)
50
+
51
+ return nil if timeout.equal?(ABSENT)
52
+
53
+ validate_timeout!(timeout, source: source.fetch(:label), path:)
54
+ rescue ConfigurationError
55
+ raise
56
+ rescue Psych::Exception
57
+ raise_configuration_error(source, "contains invalid YAML")
58
+ rescue SystemCallError
59
+ raise_configuration_error(source, "could not be read")
60
+ rescue ArgumentError
61
+ raise_configuration_error(source, "contains invalid encoding")
62
+ end
63
+
64
+ def stat_config_path(path, source)
65
+ File.lstat(path)
66
+ rescue Errno::ENOENT, Errno::ENOTDIR
67
+ nil
68
+ rescue SystemCallError
69
+ raise_configuration_error(source, "could not be read")
70
+ end
71
+
72
+ def project_source(session)
73
+ project_path = normalize_absolute_path(session.project_path)
74
+ return nil unless project_path
75
+
76
+ {
77
+ label: "project configuration",
78
+ path: File.join(project_path, PROJECT_CONFIG_FILENAME)
79
+ }
80
+ end
81
+
82
+ def user_source(env)
83
+ xdg_config_home = normalize_absolute_path(env["XDG_CONFIG_HOME"])
84
+ if xdg_config_home
85
+ return {
86
+ label: "user configuration",
87
+ path: File.join(xdg_config_home, "agent_context", "config.yml")
88
+ }
89
+ end
90
+
91
+ home = normalize_absolute_path(env["HOME"])
92
+ return nil unless home
93
+
94
+ {
95
+ label: "user configuration",
96
+ path: File.join(home, *USER_CONFIG_PATH_SEGMENTS)
97
+ }
98
+ end
99
+
100
+ def normalize_absolute_path(value)
101
+ return nil unless value.respond_to?(:to_str)
102
+
103
+ path = value.to_str
104
+ return nil if path.empty?
105
+ return path if Pathname.new(path).absolute?
106
+
107
+ nil
108
+ end
109
+
110
+ def read_utf8(path, initial_stat, source)
111
+ open_config_file(path, source) do |file|
112
+ descriptor_stat = file.stat
113
+ raise_configuration_error(source, "must be a regular file") unless descriptor_stat.file?
114
+ ensure_same_identity!(initial_stat, descriptor_stat, source)
115
+
116
+ content = file.read
117
+ content = content.dup.force_encoding(Encoding::UTF_8)
118
+ raise_configuration_error(source, "contains invalid encoding") unless content.valid_encoding?
119
+
120
+ content
121
+ end
122
+ end
123
+
124
+ def parse_yaml(content, source)
125
+ return nil if content.empty?
126
+
127
+ Psych.safe_load(
128
+ content,
129
+ permitted_classes: [],
130
+ permitted_symbols: [],
131
+ aliases: false
132
+ )
133
+ rescue Psych::Exception
134
+ raise_configuration_error(source, "contains invalid YAML")
135
+ end
136
+
137
+ def extract_timeout(raw, source)
138
+ return ABSENT if raw.nil?
139
+ return ABSENT if raw == {}
140
+
141
+ raise_configuration_error(source, "must be a mapping") unless raw.is_a?(Hash)
142
+
143
+ unknown_keys = raw.keys - ["summarize"]
144
+ raise_configuration_error(source, "contains unsupported settings") unless unknown_keys.empty?
145
+
146
+ summarize = raw.fetch("summarize")
147
+ raise_configuration_error(source, "summarize must be a mapping") unless summarize.is_a?(Hash)
148
+ return ABSENT if summarize.empty?
149
+
150
+ unknown_summarize_keys = summarize.keys - ["timeout_seconds"]
151
+ unless unknown_summarize_keys.empty?
152
+ raise_configuration_error(source,
153
+ "contains unsupported summarize settings")
154
+ end
155
+
156
+ return ABSENT unless summarize.key?("timeout_seconds")
157
+
158
+ summarize.fetch("timeout_seconds")
159
+ end
160
+
161
+ def validate_timeout!(value, source:, path: nil)
162
+ unless value.is_a?(Integer) || value.is_a?(Float)
163
+ raise_configuration_error({ label: source, path: },
164
+ "timeout_seconds must be an Integer or Float between " \
165
+ "#{MIN_TIMEOUT_SECONDS} and #{MAX_TIMEOUT_SECONDS}")
166
+ end
167
+
168
+ unless value.finite? &&
169
+ value >= MIN_TIMEOUT_SECONDS && value <= MAX_TIMEOUT_SECONDS
170
+ raise_configuration_error({ label: source, path: },
171
+ "timeout_seconds must be a finite number between " \
172
+ "#{MIN_TIMEOUT_SECONDS} and #{MAX_TIMEOUT_SECONDS}")
173
+ end
174
+
175
+ value
176
+ end
177
+
178
+ def open_config_file(path, source)
179
+ flags = File::RDONLY
180
+ flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
181
+
182
+ File.open(path, flags) do |file|
183
+ file.binmode
184
+ yield file
185
+ end
186
+ rescue SystemCallError, IOError
187
+ raise_configuration_error(source, "could not be read")
188
+ end
189
+
190
+ def ensure_same_identity!(initial_stat, descriptor_stat, source)
191
+ return if initial_stat.dev == descriptor_stat.dev && initial_stat.ino == descriptor_stat.ino
192
+
193
+ raise_configuration_error(source, "changed while being read")
194
+ end
195
+
196
+ def raise_configuration_error(source, reason)
197
+ label = source.fetch(:label)
198
+ path = source[:path]
199
+ location = path ? " at #{path}" : ""
200
+
201
+ raise ConfigurationError, "Invalid #{label}#{location}: #{reason}."
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class Error < StandardError; end
6
+ class ConfigurationError < Error; end
7
+ class SessionNotFound < Error; end
8
+ class AmbiguousSession < Error; end
9
+ class CurrentSessionUnavailable < Error; end
10
+ class UnsupportedAgent < Error; end
11
+ class SummarizerUnavailable < Error; end
12
+ class SummarizerFailed < Error; end
13
+ class InvalidSummary < Error; end
14
+ end
15
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class EvidenceCollector
6
+ DOCUMENT_BASENAMES = %w[agents.md claude.md readme readme.md].freeze
7
+ DOCUMENT_EXTENSIONS = %w[.md .markdown .txt .pdf .doc .docx .odt .rtf].freeze
8
+ PATH_KEYS = %w[path file_path filename source destination target].freeze
9
+ # rubocop:disable-next Layout/LineLength -- splitting this regexp would obscure its URL grammar
10
+ SCHEMELESS_URL_PATTERN = %r{(?<![A-Za-z0-9_./-])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?::\d+)?(?:/[^\s"'<>?#]+)+(?:\?[^\s"'<>#]*)?(?:#[^\s"'<>]*)?}i
11
+ SCHEME_URL_PATTERN = %r{[A-Za-z][A-Za-z0-9+\-.]*://[^\s"'<>]+}
12
+ TOOL_ACTIONS = {
13
+ "Read" => :read,
14
+ "read_file" => :read,
15
+ "view_image" => :read,
16
+ "Write" => :modified,
17
+ "Edit" => :modified,
18
+ "MultiEdit" => :modified,
19
+ "apply_patch" => :modified,
20
+ "move_file" => :modified
21
+ }.freeze
22
+ PATH_SCAN_PATTERN = %r{(?:\.\.?/|/)?[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)+}
23
+
24
+ Result = Data.define(:files, :tool_activity) do
25
+ def documents
26
+ files.select { |item| EvidenceCollector.document_item?(item) }.freeze
27
+ end
28
+ end
29
+
30
+ class << self
31
+ def document_item?(item)
32
+ item.kind == :file && document_path?(item.label)
33
+ end
34
+
35
+ def document_path?(path)
36
+ basename = File.basename(path).downcase
37
+ extension = File.extname(path).downcase
38
+
39
+ DOCUMENT_BASENAMES.include?(basename) || DOCUMENT_EXTENSIONS.include?(extension)
40
+ end
41
+ end
42
+
43
+ def call(transcript)
44
+ tool_activity = []
45
+ files = {}
46
+
47
+ transcript.entries.each do |entry|
48
+ entry.parts.each do |part|
49
+ next unless part.type == :tool_use
50
+
51
+ parsed_input = parse_input(part.text)
52
+ tool_activity << build_tool_item(part, parsed_input)
53
+
54
+ extract_paths(part.text, parsed_input).each do |path|
55
+ add_item(files, kind: :file, path: path, action: action_for(part.name), source_ref: part.source_ref)
56
+ end
57
+ end
58
+ end
59
+
60
+ file_items = files.values.freeze
61
+
62
+ Result.new(files: file_items, tool_activity: tool_activity.freeze)
63
+ end
64
+
65
+ private
66
+
67
+ def build_tool_item(part, parsed_input)
68
+ Item.new(
69
+ kind: :tool,
70
+ label: part.name || "(unknown)",
71
+ evidence: :observed,
72
+ source_refs: [part.source_ref],
73
+ attributes: tool_attributes(part.call_id, parsed_input)
74
+ )
75
+ end
76
+
77
+ def tool_attributes(call_id, parsed_input)
78
+ attributes = { call_id: call_id }
79
+ return attributes unless parsed_input.is_a?(Hash)
80
+
81
+ attributes[:input_keys] = parsed_input.keys.map { |key| String.new(key.to_s).freeze }.sort.freeze
82
+ attributes
83
+ end
84
+
85
+ def parse_input(raw_input)
86
+ return unless raw_input.is_a?(String)
87
+
88
+ JSON.parse(raw_input)
89
+ rescue JSON::ParserError, TypeError
90
+ nil
91
+ end
92
+
93
+ def extract_paths(raw_input, parsed_input)
94
+ keyed_paths = extract_keyed_paths(parsed_input)
95
+ return keyed_paths unless keyed_paths.empty?
96
+
97
+ scan_plain_text(raw_input)
98
+ end
99
+
100
+ def extract_keyed_paths(parsed_input)
101
+ return [] unless parsed_input.is_a?(Array) || parsed_input.is_a?(Hash)
102
+
103
+ seen = {}
104
+ collected = []
105
+ collect_keyed_paths(parsed_input, seen, collected, under_path_key: false)
106
+ collected.freeze
107
+ end
108
+
109
+ def collect_keyed_paths(value, seen, collected, under_path_key:)
110
+ case value
111
+ when Hash
112
+ value.each do |key, child|
113
+ collect_keyed_paths(child, seen, collected, under_path_key: under_path_key || PATH_KEYS.include?(key.to_s))
114
+ end
115
+ when Array
116
+ value.each do |child|
117
+ collect_keyed_paths(child, seen, collected, under_path_key: under_path_key)
118
+ end
119
+ when String
120
+ append_unique_path(collected, seen, value) if valid_keyed_path?(value) && under_path_key
121
+ end
122
+ end
123
+
124
+ def scan_plain_text(raw_input)
125
+ return [].freeze unless raw_input.is_a?(String)
126
+
127
+ spans = url_spans(raw_input)
128
+ span_index = 0
129
+ seen = {}
130
+
131
+ raw_input.to_enum(:scan, PATH_SCAN_PATTERN).each_with_object([]) do |_ignored, collected|
132
+ match = Regexp.last_match
133
+ span_index = advance_span_index(spans, span_index, match.begin(0))
134
+ next if overlap?(spans, span_index, match.begin(0), match.end(0))
135
+
136
+ append_unique_path(collected, seen, match[0])
137
+ end.freeze
138
+ end
139
+
140
+ def valid_keyed_path?(value)
141
+ !value.empty? && !value.include?("\0") && !url_like?(value)
142
+ end
143
+
144
+ def url_like?(value)
145
+ value.match?(/\A#{SCHEME_URL_PATTERN}\z/o) || value.match?(/\A#{SCHEMELESS_URL_PATTERN}\z/o)
146
+ end
147
+
148
+ def url_spans(raw_input)
149
+ spans = []
150
+
151
+ [SCHEME_URL_PATTERN, SCHEMELESS_URL_PATTERN].each do |pattern|
152
+ raw_input.to_enum(:scan, pattern).each do
153
+ match = Regexp.last_match
154
+ spans << [match.begin(0), match.end(0)]
155
+ end
156
+ end
157
+
158
+ merge_spans(spans)
159
+ end
160
+
161
+ def merge_spans(spans)
162
+ return [].freeze if spans.empty?
163
+
164
+ sorted_spans = spans.sort_by { |start_index, end_index| [start_index, end_index] }
165
+ merged = [sorted_spans.first.dup]
166
+
167
+ sorted_spans.drop(1).each do |start_index, end_index|
168
+ current_span = merged.last
169
+
170
+ if start_index <= current_span[1]
171
+ current_span[1] = [current_span[1], end_index].max
172
+ else
173
+ merged << [start_index, end_index]
174
+ end
175
+ end
176
+
177
+ merged.freeze
178
+ end
179
+
180
+ def advance_span_index(spans, span_index, match_start)
181
+ span_index += 1 while span_index < spans.length && spans[span_index][1] <= match_start
182
+ span_index
183
+ end
184
+
185
+ def overlap?(spans, span_index, _match_start, match_end)
186
+ span_index < spans.length && spans[span_index][0] < match_end
187
+ end
188
+
189
+ def append_unique_path(collected, seen, path)
190
+ return if seen.key?(path)
191
+
192
+ seen[path] = true
193
+ collected << path
194
+ end
195
+
196
+ def action_for(tool_name)
197
+ TOOL_ACTIONS.fetch(tool_name.to_s, :referenced)
198
+ end
199
+
200
+ def add_item(collection, kind:, path:, action:, source_ref:)
201
+ key = [kind, path, action]
202
+ existing = collection[key]
203
+
204
+ if existing
205
+ return if existing.source_refs.include?(source_ref)
206
+
207
+ collection[key] = Item.new(
208
+ kind: kind,
209
+ label: path,
210
+ evidence: :observed,
211
+ source_refs: existing.source_refs + [source_ref],
212
+ attributes: existing.attributes
213
+ )
214
+ return
215
+ end
216
+
217
+ collection[key] = Item.new(
218
+ kind: kind,
219
+ label: path,
220
+ evidence: :observed,
221
+ source_refs: [source_ref],
222
+ attributes: { action: action }
223
+ )
224
+ end
225
+ end
226
+ end
227
+ end