phronomy 0.6.0 → 0.7.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +338 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +242 -27
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/SECURITY.md +80 -0
  8. data/benchmark/baseline.json +9 -0
  9. data/benchmark/bench_agent_invoke.rb +105 -0
  10. data/benchmark/bench_context_assembler.rb +46 -0
  11. data/benchmark/bench_regression.rb +171 -0
  12. data/benchmark/bench_token_estimator.rb +44 -0
  13. data/benchmark/bench_tool_schema.rb +69 -0
  14. data/benchmark/bench_vector_store.rb +39 -0
  15. data/benchmark/bench_workflow.rb +55 -0
  16. data/benchmark/run_all.rb +118 -0
  17. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  18. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  19. data/docs/decisions/003-event-loop-singleton.md +48 -0
  20. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
  21. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  22. data/docs/decisions/006-no-built-in-guardrails.md +48 -0
  23. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  24. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  25. data/docs/decisions/009-state-store-abstraction.md +141 -0
  26. data/lib/phronomy/agent/base.rb +194 -12
  27. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  28. data/lib/phronomy/agent/checkpoint.rb +1 -0
  29. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  30. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  31. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  32. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  33. data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
  34. data/lib/phronomy/agent/fsm.rb +15 -0
  35. data/lib/phronomy/agent/handoff.rb +3 -0
  36. data/lib/phronomy/agent/orchestrator.rb +123 -11
  37. data/lib/phronomy/agent/parallel_tool_chat.rb +21 -4
  38. data/lib/phronomy/agent/react_agent.rb +8 -6
  39. data/lib/phronomy/agent/runner.rb +2 -0
  40. data/lib/phronomy/agent/shared_state.rb +11 -0
  41. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  42. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  43. data/lib/phronomy/cancellation_token.rb +92 -0
  44. data/lib/phronomy/configuration.rb +26 -2
  45. data/lib/phronomy/context/assembler.rb +6 -0
  46. data/lib/phronomy/context/compaction_context.rb +2 -0
  47. data/lib/phronomy/context/context_version_cache.rb +2 -0
  48. data/lib/phronomy/context/token_budget.rb +3 -0
  49. data/lib/phronomy/context/token_estimator.rb +9 -2
  50. data/lib/phronomy/context/trigger_context.rb +1 -0
  51. data/lib/phronomy/context/trim_context.rb +4 -0
  52. data/lib/phronomy/embeddings/base.rb +5 -2
  53. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  54. data/lib/phronomy/eval/comparison.rb +2 -0
  55. data/lib/phronomy/eval/dataset.rb +4 -0
  56. data/lib/phronomy/eval/metrics.rb +6 -0
  57. data/lib/phronomy/eval/runner.rb +2 -0
  58. data/lib/phronomy/eval/scorer/base.rb +1 -0
  59. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  60. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  61. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  62. data/lib/phronomy/event_loop.rb +114 -7
  63. data/lib/phronomy/fsm_session.rb +8 -1
  64. data/lib/phronomy/generator_verifier.rb +2 -0
  65. data/lib/phronomy/guardrail/base.rb +3 -0
  66. data/lib/phronomy/knowledge_source/base.rb +6 -2
  67. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  68. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  69. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  70. data/lib/phronomy/loader/base.rb +1 -0
  71. data/lib/phronomy/loader/csv_loader.rb +2 -0
  72. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  73. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  74. data/lib/phronomy/output_parser/base.rb +1 -0
  75. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  76. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  77. data/lib/phronomy/prompt_template.rb +5 -0
  78. data/lib/phronomy/runnable.rb +20 -3
  79. data/lib/phronomy/splitter/base.rb +2 -0
  80. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  81. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  82. data/lib/phronomy/state_store/base.rb +48 -0
  83. data/lib/phronomy/state_store/in_memory.rb +62 -0
  84. data/lib/phronomy/tool/agent_tool.rb +1 -0
  85. data/lib/phronomy/tool/base.rb +189 -27
  86. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  87. data/lib/phronomy/tracing/base.rb +3 -0
  88. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  89. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  90. data/lib/phronomy/vector_store/base.rb +33 -7
  91. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  92. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  93. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  94. data/lib/phronomy/version.rb +1 -1
  95. data/lib/phronomy/workflow.rb +96 -7
  96. data/lib/phronomy/workflow_context.rb +54 -4
  97. data/lib/phronomy/workflow_runner.rb +35 -7
  98. data/lib/phronomy.rb +70 -1
  99. data/scripts/api_snapshot.rb +91 -0
  100. data/scripts/check_api_annotations.rb +68 -0
  101. data/scripts/check_private_enforcement.rb +93 -0
  102. data/scripts/check_readme_runnable.rb +98 -0
  103. data/scripts/run_mutation.sh +46 -0
  104. metadata +45 -2
@@ -17,8 +17,11 @@ module Phronomy
17
17
  # determined by the declared state machine topology, never by Phronomy internals.
18
18
  #
19
19
  # Entry and exit actions are registered as state_machines +after_transition to:+
20
- # and +before_transition from:+ callbacks respectively. The WorkflowContext is
21
- # mutable; actions receive it and modify fields in place.
20
+ # and +before_transition from:+ callbacks respectively. Entry actions may either
21
+ # mutate the context in place or return a new context (e.g. via +s.merge(...)+).
22
+ # When an entry action returns a Phronomy::WorkflowContext, that value replaces
23
+ # the current context; otherwise the return value is ignored.
24
+ # Exit actions are always mutation-in-place; their return value is ignored.
22
25
  #
23
26
  # The sole exception is the initial state: state_machines does not fire transition
24
27
  # callbacks on initialization, so the entry action for the entry point is invoked
@@ -35,13 +38,14 @@ module Phronomy
35
38
  # 2. <event_name> — external events triggered by human input, originating
36
39
  # from wait states
37
40
  # (declared with +transition from: :awaiting, on: :approve, to: :run+)
41
+ # @api private
38
42
  class WorkflowRunner
39
43
  include Phronomy::Runnable
40
44
 
41
45
  # Sentinel value for the terminal state of a workflow.
42
46
  FINISH = :__end__
43
47
 
44
- def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [])
48
+ def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [], state_store: nil)
45
49
  @state_class = state_class
46
50
  @entry_actions = entry_actions # { state_name => [callable, ...] }
47
51
  @declared_states = declared_states
@@ -50,13 +54,15 @@ module Phronomy
50
54
  @external_events = external_events # { name => [{from:, to:, guard:}, ...] }
51
55
  @entry_point = entry_point
52
56
  @wait_state_names = wait_state_names
57
+ @state_store = state_store
53
58
  @phase_machine_class = build_phase_machine_class(auto_transitions, exit_actions)
54
59
  end
55
60
 
56
61
  # Executes the workflow from the initial state.
57
62
  # @param input [Hash] initial context field values
58
- # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
63
+ # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id:, state_store: }
59
64
  # @return [Object] final context (includes Phronomy::WorkflowContext)
65
+ # @api private
60
66
  def invoke(input, config: {})
61
67
  caller_meta = {}
62
68
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
@@ -65,13 +71,23 @@ module Phronomy
65
71
  trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
66
72
  thread_id = config[:thread_id] || SecureRandom.uuid
67
73
  recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
68
- state = @state_class.new(**input)
74
+
75
+ store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
76
+ snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
77
+ initial_fields = if snapshot && snapshot[:fields]
78
+ snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
79
+ else
80
+ input
81
+ end
82
+
83
+ state = @state_class.new(**initial_fields)
69
84
  state.set_graph_metadata(thread_id: thread_id)
70
85
  result = if Phronomy.configuration.event_loop
71
86
  run_via_event_loop(state, recursion_limit: recursion_limit)
72
87
  else
73
88
  run_workflow(state, recursion_limit: recursion_limit)
74
89
  end
90
+ store&.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) if config[:thread_id]
75
91
  [result, nil]
76
92
  end
77
93
  end
@@ -80,6 +96,7 @@ module Phronomy
80
96
  # @param state [Object] halted context
81
97
  # @param input [Hash, nil] optional field updates to merge before resuming
82
98
  # @return [Object] final context
99
+ # @api private
83
100
  def resume(state:, input: nil)
84
101
  send_event(state: state, event: :resume, input: input)
85
102
  end
@@ -93,6 +110,7 @@ module Phronomy
93
110
  # @param event [Symbol] named event or +:resume+ for generic resumption
94
111
  # @param input [Hash, nil] optional field updates to merge before resuming
95
112
  # @return [Object] final context
113
+ # @api private
96
114
  def send_event(state:, event:, input: nil)
97
115
  state = state.merge(input) if input
98
116
  event = event.to_sym
@@ -128,6 +146,7 @@ module Phronomy
128
146
  # @param config [Hash]
129
147
  # @yield [Hash]
130
148
  # @return [Object] final context
149
+ # @api private
131
150
  def stream(input, config: {}, &block)
132
151
  thread_id = config[:thread_id] || SecureRandom.uuid
133
152
  recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
@@ -180,6 +199,7 @@ module Phronomy
180
199
  tracker = new_phase_machine(current_state)
181
200
  tracker.context = ctx
182
201
  fire_event!(tracker, resume_event, current_state)
202
+ ctx = tracker.context
183
203
  next_phase = tracker.phase.to_sym
184
204
  current_state = (next_phase == current_state) ? FINISH : next_phase
185
205
  else
@@ -189,7 +209,11 @@ module Phronomy
189
209
  tracker.context = ctx
190
210
  # state_machines only fires after_transition callbacks on transitions.
191
211
  # The entry point has no prior transition, so we invoke its entry actions directly.
192
- @entry_actions[current_state]&.each { |c| c.call(ctx) }
212
+ @entry_actions[current_state]&.each do |c|
213
+ result = c.call(ctx)
214
+ ctx = result if result.is_a?(Phronomy::WorkflowContext)
215
+ end
216
+ tracker.context = ctx
193
217
  end
194
218
 
195
219
  # Event queue: decouple action execution from transition firing.
@@ -211,6 +235,7 @@ module Phronomy
211
235
  end
212
236
 
213
237
  fire_event!(tracker, event, current_state)
238
+ ctx = tracker.context
214
239
  next_phase = tracker.phase.to_sym
215
240
  # When next_phase == current_state no transition matched → terminal state.
216
241
  current_state = (next_phase == current_state) ? FINISH : next_phase
@@ -316,10 +341,13 @@ module Phronomy
316
341
  # Entry callbacks: fire after_transition into each state.
317
342
  # Each callable is registered as a separate callback; state_machines
318
343
  # accumulates them and fires in declaration order.
344
+ # If the callable returns a WorkflowContext (e.g. via s.merge(...)),
345
+ # the returned context replaces the current one on the tracker.
319
346
  entry_acts.each do |state_name, callables|
320
347
  callables.each do |callable|
321
348
  after_transition to: state_name do |machine|
322
- callable.call(machine.context)
349
+ result = callable.call(machine.context)
350
+ machine.context = result if result.is_a?(Phronomy::WorkflowContext)
323
351
  end
324
352
  end
325
353
  end
data/lib/phronomy.rb CHANGED
@@ -23,11 +23,33 @@ module Phronomy
23
23
  class ParseError < Error; end
24
24
  class RecursionLimitError < Error; end
25
25
  class ToolError < Error; end
26
+ # Raised when an agent invocation exceeds the timeout set via +invoke_timeout+.
27
+ class TimeoutError < Error; end
26
28
 
27
29
  class ConfigurationError < Error; end
28
30
 
29
31
  class HandoffError < Error; end
30
32
 
33
+ # Raised when a network or transport layer call fails (e.g. LLM API unreachable,
34
+ # MCP server connection refused). Distinguishable from application-level errors
35
+ # so callers can apply network-specific retry logic.
36
+ class TransportError < Error; end
37
+
38
+ # Raised when the LLM API returns a rate-limit response (HTTP 429 or equivalent).
39
+ # Callers should back off and retry after the indicated delay.
40
+ class RateLimitError < TransportError; end
41
+
42
+ # Raised when the LLM API rejects the request due to an invalid or revoked API key.
43
+ # Callers should not retry without fixing the credentials.
44
+ class AuthenticationError < TransportError; end
45
+
46
+ # Raised when the prompt exceeds the model's context window limit.
47
+ class ContextLengthError < Error; end
48
+
49
+ # Raised when a workflow or agent execution is explicitly cancelled.
50
+ # Separate from TimeoutError (deadline exceeded) — this is an intentional stop.
51
+ class CancellationError < Error; end
52
+
31
53
  # Raised by {Phronomy::GeneratorVerifier#invoke} when +raise_if_untrusted: true+
32
54
  # and the pipeline's combined confidence score falls below the configured threshold.
33
55
  #
@@ -63,9 +85,56 @@ module Phronomy
63
85
  yield configuration
64
86
  end
65
87
 
66
- # Resets configuration; primarily used in tests.
88
+ # Resets the global Phronomy configuration to defaults.
89
+ #
90
+ # **Intended for test suites only.** Calling this in a production process
91
+ # will drop all runtime configuration (tracer, model, tokenizer, etc.)
92
+ # globally and immediately affect all subsequent agent and workflow calls.
93
+ #
94
+ # **Parallel test suites warning:** When tests run in parallel (e.g.
95
+ # `parallel_tests` or `parallel_rspec`), +reset_configuration!+ in one
96
+ # worker will clear configuration shared with other workers in the same
97
+ # process. Prefer process-isolation strategies (forked workers) over
98
+ # thread-based parallelism when using this method.
99
+ #
100
+ # Typical usage in a sequential test suite:
101
+ # after { Phronomy.reset_configuration! }
67
102
  def reset_configuration!
68
103
  @configuration = Configuration.new
69
104
  end
105
+
106
+ # Yields the current {Configuration} object, then restores the original
107
+ # configuration on exit (even if the block raises).
108
+ #
109
+ # Intended for test helpers that need to temporarily override settings
110
+ # without permanently mutating the global configuration.
111
+ #
112
+ # @yield [config] the current {Configuration} instance (mutable)
113
+ # @example
114
+ # Phronomy.with_configuration do |c|
115
+ # c.logger = Logger.new($stdout)
116
+ # end
117
+ # @api public
118
+ def with_configuration
119
+ original = @configuration&.dup
120
+ yield configuration
121
+ ensure
122
+ @configuration = original
123
+ end
124
+
125
+ # Resets all Phronomy runtime state: configuration and the EventLoop
126
+ # singleton (if running).
127
+ #
128
+ # **Intended for test suites only.** Stops any running EventLoop thread,
129
+ # clears the EventLoop singleton, and resets configuration to defaults.
130
+ # Call once before/after each example to ensure test isolation.
131
+ #
132
+ # @example
133
+ # config.around { |ex| Phronomy.reset_runtime! ; ex.run ; Phronomy.reset_runtime! }
134
+ # @api public
135
+ def reset_runtime!
136
+ Phronomy::EventLoop.reset!
137
+ @configuration = Configuration.new
138
+ end
70
139
  end
71
140
  end
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # scripts/api_snapshot.rb
5
+ #
6
+ # Dumps the public instance methods of all Stable/Beta public API classes to
7
+ # JSON. The snapshot is stored in spec/fixtures/api_snapshot.json and is used
8
+ # by spec/phronomy/api_compatibility_spec.rb to detect unintended API removals.
9
+ #
10
+ # Usage:
11
+ # # Regenerate spec/fixtures/api_snapshot.json (run when intentionally adding
12
+ # # or removing public API methods after updating the stability table):
13
+ # ruby scripts/api_snapshot.rb --write
14
+ #
15
+ # # Print snapshot to stdout (useful for manual inspection):
16
+ # ruby scripts/api_snapshot.rb
17
+
18
+ require "json"
19
+ require "fileutils"
20
+ require_relative "../lib/phronomy"
21
+
22
+ # Classes and modules whose public API is tracked.
23
+ # Add an entry whenever a new class/module is promoted to Stable or Beta in README.md.
24
+ PUBLIC_API_ENTRIES = [
25
+ # Stable
26
+ Phronomy::Agent::Base,
27
+ Phronomy::Tool::Base,
28
+ Phronomy::Workflow,
29
+ Phronomy::WorkflowContext,
30
+ Phronomy::Runnable,
31
+ Phronomy::PromptTemplate,
32
+ # Beta
33
+ Phronomy::Agent::ReactAgent,
34
+ Phronomy::Agent::Orchestrator,
35
+ Phronomy::Agent::TeamCoordinator,
36
+ Phronomy::Guardrail::InputGuardrail,
37
+ Phronomy::Guardrail::OutputGuardrail,
38
+ Phronomy::VectorStore::Base,
39
+ Phronomy::VectorStore::InMemory,
40
+ Phronomy::Embeddings::Base,
41
+ Phronomy::KnowledgeSource::Base,
42
+ Phronomy::KnowledgeSource::StaticKnowledge,
43
+ Phronomy::KnowledgeSource::RAGKnowledge,
44
+ Phronomy::Tracing::Base,
45
+ Phronomy::Tracing::NullTracer,
46
+ Phronomy::Eval::Runner
47
+ ].freeze
48
+
49
+ # Baseline methods common to all Ruby objects — excluded from the snapshot.
50
+ BASELINE_INSTANCE_METHODS = (
51
+ Object.public_instance_methods |
52
+ Kernel.public_instance_methods
53
+ ).uniq.freeze
54
+
55
+ BASELINE_CLASS_METHODS = (
56
+ Class.public_methods |
57
+ Module.public_methods
58
+ ).uniq.freeze
59
+
60
+ def snapshot_entry(klass)
61
+ if klass.instance_of?(Module)
62
+ # Module — capture instance methods defined in this module only
63
+ own_methods = klass.public_instance_methods(false).sort
64
+ {
65
+ "name" => klass.name,
66
+ "type" => "module",
67
+ "public_instance_methods" => own_methods
68
+ }
69
+ else
70
+ # Class — capture public instance methods minus universal baseline
71
+ instance_methods = (klass.public_instance_methods - BASELINE_INSTANCE_METHODS).sort
72
+ class_methods = (klass.public_methods(false) - BASELINE_CLASS_METHODS).sort
73
+ {
74
+ "name" => klass.name,
75
+ "type" => "class",
76
+ "public_instance_methods" => instance_methods,
77
+ "public_class_methods" => class_methods
78
+ }
79
+ end
80
+ end
81
+
82
+ snapshot = PUBLIC_API_ENTRIES.map { |entry| snapshot_entry(entry) }
83
+
84
+ if ARGV.include?("--write")
85
+ path = File.expand_path("../spec/fixtures/api_snapshot.json", __dir__)
86
+ FileUtils.mkdir_p(File.dirname(path))
87
+ File.write(path, JSON.pretty_generate(snapshot) + "\n")
88
+ puts "Wrote #{path}"
89
+ else
90
+ puts JSON.pretty_generate(snapshot)
91
+ end
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # check_api_annotations.rb
5
+ #
6
+ # Verifies that every YARD-documented public method in lib/ carries either
7
+ # "@api public" or "@api private".
8
+ #
9
+ # A method is considered "YARD-documented" when its preceding comment block
10
+ # contains at least one @param, @return, @raise, @yield, @example, or
11
+ # @overload tag. Methods with only a plain prose description (no @ tags)
12
+ # are exempt.
13
+ #
14
+ # Usage (run from the phronomy/ repository root):
15
+ # ruby scripts/check_api_annotations.rb
16
+ #
17
+ # Exit codes:
18
+ # 0 — all documented methods carry @api annotations
19
+ # 1 — one or more documented methods are missing @api annotations
20
+
21
+ lib_dir = File.expand_path("../lib", __dir__)
22
+
23
+ unless File.directory?(lib_dir)
24
+ warn "ERROR: lib directory not found at #{lib_dir}"
25
+ exit 1
26
+ end
27
+
28
+ errors = []
29
+
30
+ Dir.glob(File.join(lib_dir, "**", "*.rb")).sort.each do |file|
31
+ lines = File.readlines(file)
32
+
33
+ lines.each_with_index do |line, i|
34
+ next unless line.match?(/^\s*def\s+\w/)
35
+
36
+ # Collect the contiguous comment block immediately above this def.
37
+ comment_lines = []
38
+ j = i - 1
39
+ while j >= 0 && lines[j].match?(/^\s*#/)
40
+ comment_lines.unshift(lines[j])
41
+ j -= 1
42
+ end
43
+
44
+ next if comment_lines.empty?
45
+
46
+ comment = comment_lines.join
47
+
48
+ # Only lint methods that carry at least one YARD type tag.
49
+ next unless comment.match?(/#[ \t]+@(param|return|raise|yield|example|overload)/)
50
+
51
+ # Pass if an @api tag is already present.
52
+ next if comment.match?(/#[ \t]+@api[ \t]+(public|private)/)
53
+
54
+ rel_path = file.sub("#{lib_dir}/../", "")
55
+ m = line.match(/def\s+(\w+[!?=]?)/)
56
+ method_name = m ? m[1] : "unknown"
57
+ errors << "#{rel_path}:#{i + 1} def #{method_name} (missing @api public or @api private)"
58
+ end
59
+ end
60
+
61
+ if errors.empty?
62
+ puts "OK: all YARD-documented methods carry @api annotations"
63
+ exit 0
64
+ else
65
+ puts "FAIL: #{errors.size} method(s) missing @api annotation:"
66
+ errors.each { |e| puts " #{e}" }
67
+ exit 1
68
+ end
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # check_private_enforcement.rb
5
+ #
6
+ # Verifies that every instance method annotated @api private in lib/ is
7
+ # actually non-public at the Ruby level (i.e., NOT in Module#public_instance_methods).
8
+ #
9
+ # Class methods (def self.xxx) are excluded from this check because their
10
+ # visibility is managed separately on the singleton class and rarely causes
11
+ # accidental public exposure to consumers.
12
+ #
13
+ # Usage (run from the phronomy/ repository root):
14
+ # bundle exec ruby scripts/check_private_enforcement.rb
15
+ #
16
+ # Exit codes:
17
+ # 0 — all @api private instance methods are non-public (or have no Ruby def)
18
+ # 1 — one or more @api private instance methods are exposed as public
19
+
20
+ require "bundler/setup"
21
+ require_relative "../lib/phronomy"
22
+
23
+ lib_dir = File.expand_path("../lib", __dir__)
24
+
25
+ unless File.directory?(lib_dir)
26
+ warn "ERROR: lib directory not found at #{lib_dir}"
27
+ exit 1
28
+ end
29
+
30
+ # Step 1: Collect instance methods annotated @api private via static analysis.
31
+ api_private_entries = []
32
+
33
+ Dir.glob(File.join(lib_dir, "**", "*.rb")).sort.each do |file|
34
+ lines = File.readlines(file)
35
+
36
+ lines.each_with_index do |line, i|
37
+ next unless line.match?(/^\s*#\s*@api\s+private\s*$/)
38
+
39
+ # Advance past any further comment or blank lines to reach the def.
40
+ j = i + 1
41
+ j += 1 while j < lines.size && lines[j].match?(/^\s*(#|$)/)
42
+ next unless j < lines.size
43
+
44
+ # Skip class-level methods — they live on the singleton class, not as
45
+ # public instance methods accessible to consumers.
46
+ next if lines[j].match?(/def\s+self\./)
47
+
48
+ # Match both plain def and "private def".
49
+ m = lines[j].match(/^\s*(?:private\s+)?def\s+(\w+[!?=]?)/)
50
+ next unless m
51
+
52
+ rel_path = file.sub("#{lib_dir}/../", "")
53
+ api_private_entries << {name: m[1].to_sym, file: rel_path, line: j + 1}
54
+ end
55
+ end
56
+
57
+ if api_private_entries.empty?
58
+ puts "No @api private instance methods found."
59
+ exit 0
60
+ end
61
+
62
+ # Step 2: Build a map of publicly exposed instance methods across all
63
+ # Phronomy-namespaced modules/classes (own methods only, no inheritance).
64
+ all_phronomy_modules = ObjectSpace.each_object(Module).select do |mod|
65
+ mod.name&.start_with?("Phronomy")
66
+ end
67
+
68
+ public_exposure_map = {}
69
+ all_phronomy_modules.each do |mod|
70
+ mod.public_instance_methods(false).each do |meth|
71
+ (public_exposure_map[meth] ||= []) << mod.name
72
+ end
73
+ end
74
+
75
+ # Step 3: Report violations — @api private methods that are still public.
76
+ errors = []
77
+
78
+ api_private_entries.each do |entry|
79
+ exposing_modules = public_exposure_map[entry[:name]]
80
+ next unless exposing_modules
81
+
82
+ errors << "#{entry[:file]}:#{entry[:line]} def #{entry[:name]}" \
83
+ " (annotated @api private but public in: #{exposing_modules.join(", ")})"
84
+ end
85
+
86
+ if errors.empty?
87
+ puts "OK: all #{api_private_entries.size} @api private instance methods are non-public."
88
+ exit 0
89
+ else
90
+ warn "ERROR: #{errors.size} @api private instance method(s) are exposed as public:"
91
+ errors.each { |e| warn " #{e}" }
92
+ exit 1
93
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ # scripts/check_readme_runnable.rb
4
+ #
5
+ # Extracts ```ruby runnable blocks from README.md and executes each in an
6
+ # isolated subprocess with a fake LLM stub to catch API drift.
7
+ #
8
+ # Any block that raises NoMethodError / ArgumentError / NameError causes a
9
+ # non-zero exit, failing the CI step.
10
+ #
11
+ # Usage (from the phronomy/ root):
12
+ # bundle exec ruby scripts/check_readme_runnable.rb
13
+
14
+ require "tempfile"
15
+ require "open3"
16
+
17
+ REPO_ROOT = File.expand_path("..", __dir__)
18
+ README_PATH = File.join(REPO_ROOT, "README.md")
19
+
20
+ # Injected before every runnable block.
21
+ # Uses the Gemfile of this project so subprocesses can load phronomy.
22
+ PREAMBLE = <<~RUBY
23
+ # frozen_string_literal: true
24
+ # --- CI preamble: stub LLM calls so no real network requests are made ---
25
+ ENV["BUNDLE_GEMFILE"] ||= "#{File.join(REPO_ROOT, "Gemfile")}"
26
+ require "bundler/setup"
27
+ require "phronomy"
28
+
29
+ # Patch invoke methods to return canned responses instead of calling the LLM.
30
+ module Phronomy
31
+ module Agent
32
+ class Base
33
+ def invoke(input = nil, **)
34
+ {output: "ci-stub-output", messages: []}
35
+ end
36
+ end
37
+
38
+ class Runner
39
+ def invoke(input = nil, **)
40
+ {output: "ci-stub-output", agent: nil, messages: []}
41
+ end
42
+ end
43
+ end
44
+
45
+ module Chain
46
+ class LLMChain
47
+ def invoke(vars = {})
48
+ "ci-stub-chain"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ # --- end CI preamble ---
54
+
55
+ RUBY
56
+
57
+ readme = File.read(README_PATH)
58
+
59
+ # Match opening fence with 'runnable' annotation: ```ruby runnable
60
+ blocks = readme.scan(/^```ruby runnable\n(.*?)^```/m).map.with_index(1) { |(code), i| [i, code] }
61
+
62
+ if blocks.empty?
63
+ puts "No 'ruby runnable' blocks found in README.md."
64
+ exit 0
65
+ end
66
+
67
+ puts "Checking #{blocks.size} runnable Ruby block(s) in README.md..."
68
+
69
+ failures = []
70
+
71
+ blocks.each do |index, code|
72
+ Tempfile.create(["readme_runnable_#{index}", ".rb"]) do |f|
73
+ f.write(PREAMBLE)
74
+ f.write(code)
75
+ f.flush
76
+
77
+ out, err, status = Open3.capture3(RbConfig.ruby, f.path)
78
+ combined = (out + err).gsub(f.path, "block ##{index}")
79
+
80
+ if status.success?
81
+ puts " OK block ##{index}"
82
+ else
83
+ failures << index
84
+ puts " FAIL block ##{index}"
85
+ # Print at most 15 lines of output to keep CI logs readable.
86
+ puts combined.lines.first(15).join
87
+ end
88
+ end
89
+ end
90
+
91
+ puts
92
+ if failures.empty?
93
+ puts "All #{blocks.size} runnable block(s) passed."
94
+ exit 0
95
+ else
96
+ puts "#{failures.size} block(s) failed: #{failures.join(", ")}"
97
+ exit 1
98
+ end
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env bash
2
+ # scripts/run_mutation.sh — Run mutation tests on core Phronomy domain classes.
3
+ #
4
+ # Usage:
5
+ # bash scripts/run_mutation.sh [SUBJECT_PATTERN]
6
+ #
7
+ # SUBJECT_PATTERN (optional): restrict to a specific subject, e.g. "Phronomy::WorkflowContext"
8
+ # When omitted, all subjects listed in .mutant.yml are tested.
9
+ #
10
+ # Requires mutant-rspec (in Gemfile development group):
11
+ # gem "mutant-rspec", "~> 0.15.1"
12
+ #
13
+ # Target: mutation score >= 80% for each listed subject.
14
+ # Baseline scores (as of initial run):
15
+ # Phronomy::WorkflowContext 84.85%
16
+ # Phronomy::Tool::Base 55.74%
17
+ #
18
+ # Note: mutation testing is slow (~1-5 min per subject). Run locally or via
19
+ # the nightly-mutation GitHub Actions workflow.
20
+
21
+ set -euo pipefail
22
+
23
+ cd "$(dirname "$0")/.."
24
+
25
+ if ! bundle exec mutant --version &>/dev/null; then
26
+ echo "ERROR: mutant is not available. Run: bundle install"
27
+ exit 1
28
+ fi
29
+
30
+ SUBJECT="${1:-}"
31
+
32
+ echo "=== Phronomy Mutation Test ==="
33
+ echo "Date: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
34
+ echo "Ruby: $(ruby --version)"
35
+ echo "Mutant: $(bundle exec mutant --version 2>&1 | grep -v warning | head -1)"
36
+ echo ""
37
+
38
+ if [[ -n "$SUBJECT" ]]; then
39
+ echo "Subject: $SUBJECT"
40
+ echo ""
41
+ bundle exec mutant run -- "$SUBJECT"
42
+ else
43
+ echo "Subjects: all (see .mutant.yml)"
44
+ echo ""
45
+ bundle exec mutant run
46
+ fi