woods 1.6.1 → 1.6.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.
@@ -24,7 +24,7 @@ module Woods
24
24
  # SqlNoiseStripper.strip_literals("SELECT 'it\\'s ok' FROM t", dialect: :mysql)
25
25
  # # => "SELECT '' FROM t"
26
26
  #
27
- module SqlNoiseStripper
27
+ module SqlNoiseStripper # rubocop:disable Metrics/ModuleLength
28
28
  # Strips SQL line comments (`-- ...`) and block comments (`/* ... */`).
29
29
  # Line comments are stripped to (but not including) the newline so that
30
30
  # newline-separated statement structure is preserved for callers that
@@ -40,7 +40,7 @@ module Woods
40
40
 
41
41
  def self.strip_comments(sql)
42
42
  out = sql.gsub(LINE_COMMENT, '')
43
- out.gsub(BLOCK_COMMENT, '')
43
+ out.gsub(BLOCK_COMMENT, ' ')
44
44
  end
45
45
 
46
46
  # Strips single-quoted string literals and (for the `:postgres` dialect)
@@ -53,7 +53,7 @@ module Woods
53
53
  # single-quote scanner.
54
54
  #
55
55
  # @param sql [String] the SQL string to process
56
- # @param dialect [Symbol] `:postgres` (default) or `:mysql`.
56
+ # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite`.
57
57
  # - `:postgres` — single-quoted strings support `''` as an apostrophe
58
58
  # escape. Backslash is treated literally and does not escape quotes.
59
59
  # Dollar-quoted strings (`$$...$$`, `$tag$...$tag$`) are also stripped.
@@ -61,13 +61,14 @@ module Woods
61
61
  # and `''` (doubled-quote) as apostrophe escapes. Dollar-quoted strings
62
62
  # are also stripped (MySQL does not use them, but stripping them is
63
63
  # harmless and keeps the two dialects consistent).
64
+ # - `:sqlite` — doubled apostrophes escape strings; dollar signs stay literal.
64
65
  # @return [String] a new string with all string literals replaced by `''`
65
66
  # @raise [ArgumentError] if an unsupported dialect is provided
66
67
  DOLLAR_QUOTED = /\$(\w*)\$.*?\$\1\$/m
67
68
  SINGLE_QUOTED_POSTGRES = /'(?:''|[^'])*'/m
68
69
  SINGLE_QUOTED_MYSQL = /'(?:\\.|''|[^'])*'/m
69
70
 
70
- SUPPORTED_DIALECTS = %i[postgres mysql].freeze
71
+ SUPPORTED_DIALECTS = %i[postgres mysql sqlite].freeze
71
72
  private_constant :SUPPORTED_DIALECTS
72
73
 
73
74
  def self.strip_literals(sql, dialect: :postgres)
@@ -77,7 +78,7 @@ module Woods
77
78
 
78
79
  # Strip dollar-quoted strings first so stray apostrophes inside them
79
80
  # do not interfere with the single-quote scanner.
80
- out = sql.gsub(DOLLAR_QUOTED, "''")
81
+ out = dialect == :sqlite ? sql : sql.gsub(DOLLAR_QUOTED, "''")
81
82
 
82
83
  pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
83
84
  out.gsub(pattern, "''")
@@ -98,7 +99,7 @@ module Woods
98
99
  # character rather than swallowing the rest of the statement.
99
100
  #
100
101
  # @param sql [String] the SQL string to process
101
- # @param dialect [Symbol] `:postgres` (default) or `:mysql` — controls
102
+ # @param dialect [Symbol] `:postgres` (default), `:mysql`, or `:sqlite` — controls
102
103
  # single-quote escape rules (see {.strip_literals}).
103
104
  # @return [String] a new string with comments removed and every string
104
105
  # literal replaced by `''`
@@ -126,7 +127,16 @@ module Woods
126
127
  out << ch
127
128
  i += 1
128
129
  end
129
- elsif ch == '$' && (tag = dollar_tag_at(sql, i))
130
+ elsif ['"', '`'].include?(ch)
131
+ close = quoted_identifier_end(sql, i, ch)
132
+ if close
133
+ out << sql[i...close]
134
+ i = close
135
+ else
136
+ out << ch
137
+ i += 1
138
+ end
139
+ elsif dialect != :sqlite && ch == '$' && (tag = dollar_tag_at(sql, i))
130
140
  close = sql.index(tag, i + tag.length)
131
141
  if close
132
142
  out << "''"
@@ -141,6 +151,7 @@ module Woods
141
151
  elsif ch == '/' && sql[i + 1] == '*'
142
152
  close = sql.index('*/', i + 2)
143
153
  if close
154
+ out << ' '
144
155
  i = close + 2
145
156
  else
146
157
  # Unterminated block comment: never under-detect. Leave it in
@@ -158,6 +169,23 @@ module Woods
158
169
  out
159
170
  end
160
171
 
172
+ # Preserve quoted identifiers while shielding their comment markers.
173
+ # @api private
174
+ def self.quoted_identifier_end(sql, start, quote)
175
+ i = start + 1
176
+ while i < sql.length
177
+ if sql[i] == quote
178
+ return i + 1 unless sql[i + 1] == quote
179
+
180
+ i += 2
181
+ else
182
+ i += 1
183
+ end
184
+ end
185
+ nil
186
+ end
187
+ private_class_method :quoted_identifier_end
188
+
161
189
  # Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
162
190
  # `$tag$`) at the start of the given slice.
163
191
  DOLLAR_TAG = /\A\$\w*\$/
@@ -120,11 +120,11 @@ module Woods
120
120
  #
121
121
  # @param sql [String, nil] the SQL string to scan
122
122
  # @return [Array<String>] identifiers in first-encounter order, deduplicated
123
- def self.identifiers_in(sql)
123
+ def self.identifiers_in(sql, dialect: nil)
124
124
  return [] if sql.nil? || sql.empty?
125
125
 
126
126
  results = []
127
- %i[postgres mysql].each do |dialect|
127
+ (dialect ? [dialect] : %i[postgres mysql]).each do |dialect|
128
128
  stripped = strip_noise(sql, dialect: dialect)
129
129
  collect_join_identifiers(stripped, results)
130
130
  collect_from_identifiers(stripped, results)
@@ -132,6 +132,49 @@ module Woods
132
132
  results.uniq
133
133
  end
134
134
 
135
+ # Table-factor prefixes, retaining commas after balanced subqueries and
136
+ # JOIN predicates. Each nested FROM/JOIN is also scanned independently.
137
+ # @param sql [String] noise-stripped SQL
138
+ # @return [Array<String>]
139
+ def self.relation_factors(sql)
140
+ sql.to_enum(:scan, /\b(?:FROM|JOIN)\s+/i).flat_map do
141
+ suffix = sql[Regexp.last_match.end(0)..]
142
+ split_top_level_commas(relation_clause(suffix))
143
+ end
144
+ end
145
+
146
+ # Quoted tokens shield punctuation and clause words; a closing parenthesis
147
+ # at depth zero ends this query's clause, not a nested table expression.
148
+ def self.relation_clause(suffix)
149
+ depth = 0
150
+ tokens = /
151
+ "(?:[^"]|"")*"|`(?:[^`]|``)*`|''|[()]|
152
+ \b(?:WHERE|GROUP|HAVING|ORDER|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|WINDOW)\b
153
+ /ix
154
+ suffix.to_enum(:scan, tokens).each do
155
+ token = Regexp.last_match[0]
156
+ start = Regexp.last_match.begin(0)
157
+ finish = Regexp.last_match.end(0)
158
+ boundary = token == ')' || relation_keyword_boundary?(suffix[0...start], suffix[finish..], token)
159
+ return suffix[0...start] if depth.zero? && boundary
160
+
161
+ depth += 1 if token == '('
162
+ depth -= 1 if token == ')'
163
+ end
164
+ suffix
165
+ end
166
+ private_class_method :relation_clause
167
+
168
+ def self.relation_keyword_boundary?(prefix, rest, token)
169
+ return false unless token.match?(/\A[A-Za-z]/)
170
+ return false if prefix.strip.empty? || prefix.match?(/(?:,|\bAS)\s*\z/i) || rest.lstrip.start_with?(',')
171
+ return rest.match?(/\A\s+BY\b/i) if %w[GROUP ORDER].include?(token.upcase)
172
+ return rest.match?(/\A\s+\w+\s+AS\b/i) if token.casecmp?('WINDOW')
173
+
174
+ true
175
+ end
176
+ private_class_method :relation_keyword_boundary?
177
+
135
178
  # @api private
136
179
  # Comments and literals must be stripped in a single combined pass —
137
180
  # stripping them separately lets a comment marker inside a literal
@@ -153,12 +196,9 @@ module Woods
153
196
 
154
197
  # @api private
155
198
  def self.collect_from_identifiers(sql, results)
156
- sql.scan(FROM_CLAUSE) do
157
- clause = Regexp.last_match[:clause]
158
- split_top_level_commas(clause).each do |chunk|
159
- ident = lead_identifier(chunk)
160
- results << ident if ident
161
- end
199
+ relation_factors(sql).each do |chunk|
200
+ ident = lead_identifier(chunk)
201
+ results << ident if ident
162
202
  end
163
203
  end
164
204
  private_class_method :collect_from_identifiers
@@ -169,7 +209,7 @@ module Woods
169
209
  depth = 0
170
210
  buf = +''
171
211
  parts = []
172
- clause.each_char do |ch|
212
+ clause.scan(/"(?:[^"]|"")*"|`(?:[^`]|``)*`|''|./m).each do |ch|
173
213
  case ch
174
214
  when '('
175
215
  depth += 1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'woods/console/sql_noise_stripper'
4
+ require 'woods/console/sqlite_read_guard'
4
5
 
5
6
  # @see Woods
6
7
  module Woods
@@ -95,17 +96,17 @@ module Woods
95
96
  [kw, /\b#{kw}\b/i]
96
97
  end.freeze
97
98
 
98
- # Frozen map of dangerous function name => regex matching a call to that function.
99
- # Used by {#check_dangerous_functions!}.
100
- DANGEROUS_FUNCTION_REGEXES = DANGEROUS_FUNCTIONS.to_h do |func|
101
- [func, /\b#{func}\s*\(/i]
102
- end.freeze
99
+ # @param dialect [Symbol, nil] Known connection dialect, when available
100
+ def initialize(dialect: nil)
101
+ @dialect = dialect
102
+ end
103
103
 
104
104
  # @raise [SqlValidationError] if the SQL is not a safe read-only statement
105
105
  def validate!(sql)
106
106
  raise SqlValidationError, 'SQL is empty' if sql.nil? || sql.strip.empty?
107
107
 
108
108
  normalized = sql.strip
109
+ SqliteReadGuard.validate!(normalized) if @dialect == :sqlite
109
110
 
110
111
  # Reject multiple statements (semicolons not inside string literals)
111
112
  if contains_multiple_statements?(normalized)
@@ -152,7 +153,7 @@ module Woods
152
153
  # @param sql [String]
153
154
  # @return [Boolean]
154
155
  def contains_multiple_statements?(sql)
155
- stripped = SqlNoiseStripper.strip_noise(sql)
156
+ stripped = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
156
157
  stripped.include?(';')
157
158
  end
158
159
 
@@ -191,8 +192,12 @@ module Woods
191
192
  # @param sql [String]
192
193
  # @raise [SqlValidationError] if a dangerous function is found
193
194
  def check_dangerous_functions!(sql)
194
- DANGEROUS_FUNCTION_REGEXES.each do |func, pattern|
195
- raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed" if sql.match?(pattern)
195
+ view = SqlNoiseStripper.strip_noise(sql, dialect: @dialect || :postgres)
196
+ view.scan(/(?:"([^"\n]+)"|`([^`]+)`|\b([a-z_][a-z0-9_]*))\s*\(/i) do |quoted, backtick, bare|
197
+ func = (quoted || backtick || bare).downcase
198
+ next unless DANGEROUS_FUNCTIONS.include?(func)
199
+
200
+ raise SqlValidationError, "Rejected: dangerous function #{func} is not allowed"
196
201
  end
197
202
  end
198
203
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'woods/console/sql_noise_stripper'
4
+ require 'woods/console/sql_table_scanner'
5
+
6
+ module Woods
7
+ module Console
8
+ # Refuse SQLite-specific syntax outside the scanner's supported grammar.
9
+ # This is a conservative read boundary, not a general-purpose SQL parser.
10
+ module SqliteReadGuard
11
+ SIMPLE_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*\z/
12
+ UNSUPPORTED_SYNTAX = /\b(?:FROM|JOIN)(?=['"`(])/i
13
+ QUOTED_IDENTIFIER = /"(?:[^"]|"")*"|`(?:[^`]|``)*`/
14
+
15
+ # @param sql [String] SQL to execute on SQLite
16
+ # @raise [SqlValidationError] when an identifier or table factor cannot be checked
17
+ # @return [void]
18
+ def self.validate!(sql)
19
+ view = SqlNoiseStripper.strip_noise(sql, dialect: :sqlite)
20
+ refuse! if view.match?(UNSUPPORTED_SYNTAX)
21
+ SqlTableScanner.relation_factors(view).each do |factor|
22
+ factor.scan(QUOTED_IDENTIFIER) { |quoted| refuse! unless quoted[1...-1].match?(SIMPLE_IDENTIFIER) }
23
+ refuse! unless supported_factor?(factor.strip)
24
+ end
25
+ end
26
+
27
+ # A SELECT/WITH subquery has its own independently scanned table factors.
28
+ # Parenthesized table groups and string-quoted names are refused rather
29
+ # than silently omitted from the blocked-table scan.
30
+ def self.supported_factor?(factor)
31
+ return true if factor.match?(/\A\(\s*(?:SELECT|WITH)\b/i)
32
+
33
+ match = SqlTableScanner::LEAD_IDENT.match(factor)
34
+ match && factor[match.end(0)..].match?(/\A(?:\s|,|\)|\z)/)
35
+ end
36
+ private_class_method :supported_factor?
37
+
38
+ def self.refuse!
39
+ raise SqlValidationError,
40
+ 'Rejected: unsupported SQLite identifier or table-reference syntax. ' \
41
+ 'Use simple bare or double-quoted identifiers and SELECT subqueries.'
42
+ end
43
+ private_class_method :refuse!
44
+ end
45
+ end
46
+ end
@@ -32,10 +32,10 @@ module Woods
32
32
 
33
33
  def active? = !(@blocked_bare.empty? && @blocked_qualified.empty?)
34
34
 
35
- def check_sql!(sql)
35
+ def check_sql!(sql, dialect: nil)
36
36
  return unless active? && sql&.length&.positive?
37
37
 
38
- SqlTableScanner.identifiers_in(sql).each do |raw|
38
+ SqlTableScanner.identifiers_in(sql, dialect: dialect).each do |raw|
39
39
  raise TableGateError, reject_message(raw) if blocked?(raw)
40
40
  end
41
41
  end
@@ -5,7 +5,7 @@
5
5
  # Metrics/ModuleLength is disabled here because the module body is almost
6
6
  # entirely declarative data, not imperative logic. Decomposition would just
7
7
  # scatter the tool catalogue across many files with no readability gain.
8
- # rubocop:disable Metrics/ModuleLength
8
+ # rubocop:disable-next Metrics/ModuleLength
9
9
  module Woods
10
10
  module Console
11
11
  module Server
@@ -549,4 +549,3 @@ module Woods
549
549
  end
550
550
  end
551
551
  end
552
- # rubocop:enable Metrics/ModuleLength
@@ -98,13 +98,12 @@ module Woods
98
98
  # @param scope [Hash, nil] Filter conditions
99
99
  # @param columns [Array<String>, nil] Columns to include
100
100
  # @return [Hash] Bridge request
101
- # rubocop:disable Metrics/ParameterLists
101
+ # rubocop:disable-next Metrics/ParameterLists
102
102
  def console_recent(model:, order_by: 'created_at', direction: 'desc', limit: 10, scope: nil, columns: nil)
103
103
  limit = [limit, 50].min
104
104
  { tool: 'recent', params: { model: model, order_by: order_by, direction: direction,
105
105
  limit: limit, scope: scope, columns: columns }.compact }
106
106
  end
107
- # rubocop:enable Metrics/ParameterLists
108
107
 
109
108
  # System health check.
110
109
  #
@@ -61,7 +61,7 @@ module Woods
61
61
  # @param scope [Hash, nil] Filter conditions
62
62
  # @param limit [Integer, nil] Row limit (max 10000)
63
63
  # @return [Hash] Bridge request
64
- # rubocop:disable Metrics/ParameterLists
64
+ # rubocop:disable-next Metrics/ParameterLists
65
65
  def console_query(model:, select:, joins: nil, group_by: nil, having: nil, order: nil, scope: nil, limit: nil)
66
66
  limit = [limit, MAX_QUERY_LIMIT].min if limit
67
67
  {
@@ -78,7 +78,6 @@ module Woods
78
78
  }.compact
79
79
  }
80
80
  end
81
- # rubocop:enable Metrics/ParameterLists
82
81
  end
83
82
  end
84
83
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module Woods
6
+ module MCP
7
+ # Keep the SDK DNS-rebinding guard aligned with Woods' explicit allowlist.
8
+ # Empty configuration retains the SDK's strict loopback defaults.
9
+ module HttpTransportOptions
10
+ def self.for(origins)
11
+ normalized = Array(origins).map { |origin| origin.strip.downcase.delete_suffix('/') }.reject(&:empty?)
12
+ return {} if normalized.empty?
13
+
14
+ hosts = normalized.filter_map do |origin|
15
+ uri = URI.parse(origin)
16
+ uri.host if %w[http https].include?(uri.scheme)
17
+ rescue URI::InvalidURIError
18
+ nil
19
+ end
20
+ { allowed_origins: normalized, allowed_hosts: hosts.uniq }
21
+ end
22
+ end
23
+ end
24
+ end
@@ -93,15 +93,12 @@ module Woods
93
93
  )
94
94
  end
95
95
 
96
- server = ::MCP::Server.new(
96
+ server = VersionAwareServer.new(
97
97
  name: 'woods',
98
98
  version: Woods::VERSION,
99
99
  resources: resources,
100
100
  resource_templates: resource_templates
101
101
  )
102
- # Rewrite "Tool not found" into version-aware update guidance for agents
103
- # running against an older gem than the skill they're following assumes.
104
- server.singleton_class.prepend(VersionAwareToolDispatch)
105
102
 
106
103
  define_lookup_tool(server, reader, respond, respond_err, renderer)
107
104
  define_search_tool(server, reader, respond, respond_err, renderer)
@@ -200,11 +197,7 @@ module Woods
200
197
  meta[:config_key] = config_key if config_key
201
198
  meta[:doc_link] = doc_link if doc_link
202
199
  meta.merge!(extra) unless extra.empty?
203
- ::MCP::Tool::Response.new(
204
- [{ type: 'text', text: message }],
205
- error: true,
206
- meta: meta
207
- )
200
+ ::MCP::Tool::Response.new([{ type: 'text', text: message }], error: true, meta: meta)
208
201
  end
209
202
 
210
203
  def truncate_section(array, limit)
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'mcp'
3
4
  require_relative '../update_check'
4
5
 
5
6
  module Woods
6
7
  module MCP
7
- # Prepended onto a built +MCP::Server+ instance so that a call to a tool the
8
+ # Prepended before +MCP::Server+ initialization so that a call to a tool the
8
9
  # installed gem does not define produces version-aware, self-healing
9
10
  # guidance instead of a bare "Tool not found".
10
11
  #
@@ -12,8 +13,7 @@ module Woods
12
13
  # to call a tool that only exists in a later Woods release. The upstream
13
14
  # +mcp+ gem raises +RequestHandlerError("Tool not found: X")+ with no seam to
14
15
  # customize the message, so we intercept the built server's +call_tool+
15
- # (dispatched directly by +handle_request+, so a prepend on the instance is
16
- # honored on both the stdio and HTTP transports) and re-raise with the
16
+ # before the SDK captures its bound handler method and re-raise with the
17
17
  # installed version plus an update hint the agent can relay to the user.
18
18
  #
19
19
  # Only the genuine tool-not-found case is rewritten; every other
@@ -43,5 +43,11 @@ module Woods
43
43
  error.error_type == :invalid_params && error.message.to_s.start_with?('Tool not found:')
44
44
  end
45
45
  end
46
+
47
+ # MCP 0.23 captures method(:call_tool) during initialization. The override
48
+ # must already exist then; a later singleton prepend misses that handler.
49
+ class VersionAwareServer < ::MCP::Server
50
+ prepend VersionAwareToolDispatch
51
+ end
46
52
  end
47
53
  end
@@ -30,9 +30,8 @@ module Woods
30
30
  debug: /\b(bug|error|fix|broken|failing|wrong|issue|problem|crash|exception)\b/i,
31
31
  implement: /\b(implement|add|create|build|write|make|generate)\b/i,
32
32
  compare: /\b(compare|difference|vs|versus|between|contrast)\b/i,
33
- # rubocop:disable Layout/LineLength
33
+ # rubocop:disable-next Layout/LineLength
34
34
  framework: /\b(how does rails|what does rails|rails .+ work|work.+\brails\b|in rails\b|activerecord|actioncontroller|activejob|actionmailer|actioncable|actiontext|activestorage|solid_queue|solid_cache|solid_cable|kamal|propshaft|importmap|hotwire|turbo|stimulus|zeitwerk)\b/i,
35
- # rubocop:enable Layout/LineLength
36
35
  reference: /\b(show me|what is|what are|list|options for|api|interface|signature)\b/i,
37
36
  understand: /\b(how|why|explain|understand|what happens|describe|overview)\b/i
38
37
  }.freeze
@@ -85,7 +85,7 @@ module Woods
85
85
  # @param env [Hash] Rack environment
86
86
  # @param status [Integer] HTTP response status
87
87
  # @param duration_ms [Integer] Request duration in milliseconds
88
- # rubocop:disable Metrics/MethodLength
88
+ # rubocop:disable-next Metrics/MethodLength
89
89
  def record_request(env, status, duration_ms)
90
90
  path = env['PATH_INFO'] || ''
91
91
  return if excluded?(path)
@@ -116,7 +116,6 @@ module Woods
116
116
 
117
117
  @store.record(session_id, request_data)
118
118
  end
119
- # rubocop:enable Metrics/MethodLength
120
119
 
121
120
  # Extract session ID: X-Trace-Session header first, then session cookie, then fallback.
122
121
  #
@@ -24,7 +24,7 @@ module Woods
24
24
  # doc = assembler.assemble("abc123", budget: 8000, depth: 1)
25
25
  # puts doc.to_context
26
26
  #
27
- # rubocop:disable Metrics/ClassLength
27
+ # rubocop:disable-next Metrics/ClassLength
28
28
  class SessionFlowAssembler
29
29
  ASYNC_TYPES = %w[job mailer].to_set.freeze
30
30
 
@@ -41,7 +41,7 @@ module Woods
41
41
  # @param budget [Integer] Maximum token budget (default: 8000)
42
42
  # @param depth [Integer] Expansion depth (0=metadata only, 1=direct deps, 2+=full flow)
43
43
  # @return [SessionFlowDocument] The assembled document
44
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
44
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
45
45
  def assemble(session_id, budget: 8000, depth: 1)
46
46
  requests = @store.read(session_id)
47
47
  return empty_document(session_id) if requests.empty?
@@ -89,7 +89,6 @@ module Woods
89
89
  token_count: token_count
90
90
  )
91
91
  end
92
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
93
92
 
94
93
  private
95
94
 
@@ -115,7 +114,7 @@ module Woods
115
114
  # Resolve dependencies for a unit, separating sync deps from async side effects.
116
115
  #
117
116
  # @return [Array<String>] Non-async dependency identifiers added
118
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
117
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
119
118
  def resolve_dependencies(unit_id, seen_units, context_pool,
120
119
  side_effects, step, dependency_map, depth)
121
120
  graph = @reader.dependency_graph
@@ -154,7 +153,6 @@ module Woods
154
153
 
155
154
  added
156
155
  end
157
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/ParameterLists, Metrics/PerceivedComplexity
158
156
 
159
157
  # Expand transitive dependencies (depth 2+).
160
158
  #
@@ -249,6 +247,5 @@ module Woods
249
247
  SessionFlowDocument.new(session_id: session_id)
250
248
  end
251
249
  end
252
- # rubocop:enable Metrics/ClassLength
253
250
  end
254
251
  end
@@ -23,7 +23,7 @@ module Woods
23
23
  # doc.to_markdown # => human-readable document
24
24
  # doc.to_context # => LLM XML format
25
25
  #
26
- # rubocop:disable Metrics/ClassLength
26
+ # rubocop:disable-next Metrics/ClassLength
27
27
  class SessionFlowDocument
28
28
  attr_reader :session_id, :steps, :context_pool, :side_effects,
29
29
  :dependency_map, :token_count, :generated_at
@@ -35,7 +35,7 @@ module Woods
35
35
  # @param dependency_map [Hash<String, Array<String>>] Unit -> dependency identifiers
36
36
  # @param token_count [Integer] Estimated total tokens
37
37
  # @param generated_at [String, nil] ISO8601 timestamp (defaults to now)
38
- # rubocop:disable Metrics/ParameterLists
38
+ # rubocop:disable-next Metrics/ParameterLists
39
39
  def initialize(session_id:, steps: [], context_pool: {}, side_effects: [],
40
40
  dependency_map: {}, token_count: 0, generated_at: nil)
41
41
  @session_id = session_id
@@ -46,7 +46,6 @@ module Woods
46
46
  @token_count = token_count
47
47
  @generated_at = generated_at || Time.now.utc.iso8601
48
48
  end
49
- # rubocop:enable Metrics/ParameterLists
50
49
 
51
50
  # Serialize to a JSON-compatible Hash.
52
51
  #
@@ -86,7 +85,7 @@ module Woods
86
85
  # Render as human-readable Markdown.
87
86
  #
88
87
  # @return [String]
89
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
88
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
90
89
  def to_markdown
91
90
  lines = []
92
91
  lines << "## Session: #{@session_id}"
@@ -146,14 +145,13 @@ module Woods
146
145
 
147
146
  lines.join("\n")
148
147
  end
149
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
150
148
 
151
149
  # Render as LLM-consumable XML context.
152
150
  #
153
151
  # Follows the format from docs/CONTEXT_AND_CHUNKING.md.
154
152
  #
155
153
  # @return [String]
156
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
154
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
157
155
  def to_context
158
156
  lines = []
159
157
  header = "<session_context session_id=\"#{@session_id}\" requests=\"#{@steps.size}\" " \
@@ -201,7 +199,6 @@ module Woods
201
199
  lines << '</session_context>'
202
200
  lines.join("\n")
203
201
  end
204
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
205
202
 
206
203
  # @api private
207
204
  def self.deep_symbolize_keys(obj)
@@ -218,6 +215,5 @@ module Woods
218
215
  end
219
216
  private_class_method :deep_symbolize_keys
220
217
  end
221
- # rubocop:enable Metrics/ClassLength
222
218
  end
223
219
  end
data/lib/woods/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Woods
4
- VERSION = '1.6.1'
4
+ VERSION = '1.6.3'
5
5
  end