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.
- checksums.yaml +4 -4
- data/.mutant.yml +21 -0
- data/CHANGELOG.md +338 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +242 -27
- data/RELEASE_CHECKLIST.md +86 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +171 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +48 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/lib/phronomy/agent/base.rb +194 -12
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
- data/lib/phronomy/agent/fsm.rb +15 -0
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +123 -11
- data/lib/phronomy/agent/parallel_tool_chat.rb +21 -4
- data/lib/phronomy/agent/react_agent.rb +8 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- data/lib/phronomy/cancellation_token.rb +92 -0
- data/lib/phronomy/configuration.rb +26 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/embeddings/base.rb +5 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +2 -0
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event_loop.rb +114 -7
- data/lib/phronomy/fsm_session.rb +8 -1
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/knowledge_source/base.rb +6 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- data/lib/phronomy/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- data/lib/phronomy/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -0
- data/lib/phronomy/tool/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +189 -27
- data/lib/phronomy/tool/mcp_tool.rb +68 -13
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
- data/lib/phronomy/vector_store/base.rb +33 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +96 -7
- data/lib/phronomy/workflow_context.rb +54 -4
- data/lib/phronomy/workflow_runner.rb +35 -7
- data/lib/phronomy.rb +70 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- 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.
|
|
21
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|