hecks 3.1.1 → 3.1.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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/hecksagon/mock_stripe_payment_adapter.rb +36 -0
  3. data/examples/pizzas/bluebook/pizzas.behaviors +70 -0
  4. data/examples/pizzas/bluebook/pizzas.bluebook +138 -0
  5. data/examples/pizzas/bluebook/pizzas.hecksagon +21 -0
  6. data/examples/pizzas/bluebook/pizzas.world +3 -0
  7. data/examples/pizzas/bluebook/translations/2-77625c.bluebook +6 -0
  8. data/examples/pizzas/oidc.json +24 -0
  9. data/examples/pizzas/pizzas_behaviors.hecksagon +19 -0
  10. data/lib/hecks/cli/console.rb +5 -2
  11. data/lib/hecks/doors/cli_runner.rb +6 -0
  12. data/lib/hecks/projections/deploy/box/settings.rb +8 -1
  13. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +1 -0
  14. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +15 -0
  15. data/lib/hecks/projections/deploy/box.rb +32 -2
  16. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
  17. data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
  18. data/lib/hecks/projector/cli_projector.rb +51 -22
  19. data/lib/hecks/quality_control/adapters/agent.rb +63 -7
  20. data/lib/hecks/quality_control/adapters/agent_profile.rb +165 -0
  21. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +23 -9
  22. data/lib/hecks/version.rb +1 -1
  23. data/rust/codegen/src/commands.rs +4 -32
  24. data/rust/codegen/src/mutations.rs +74 -5
  25. data/rust/host/HECKS_RELEASE +1 -1
  26. data/rust/host/src/web/registration_receipt/tests.rs +39 -0
  27. data/rust/host/src/web/registration_receipt.rs +122 -0
  28. data/rust/host/src/web/registrations.rs +14 -2
  29. data/rust/host/src/web.rs +1 -0
  30. metadata +13 -2
@@ -403,41 +403,70 @@ module Hecks
403
403
  out = ["#{bluebook.name} — #{bluebook.vision}", "",
404
404
  " #{program} <command>! [name=value …] do something",
405
405
  " #{program} query <query> [name=value …] read something", ""]
406
-
407
- out << "commands:"
408
- out.concat(listing(commands) { |spec| spec[:summary] })
409
- out << ""
410
- out << "queries (nothing here changes anything):"
411
- out.concat(listing(questions) { |spec| first_sentence(spec[:summary]) })
406
+ out.concat(tables(commands, questions, all: options[:all], notes: aggregate_notes(bluebook)))
412
407
  out << ""
413
408
  out << " #{program} <command> --help what one command wants, and every way it refuses"
409
+ out.concat(all_hint(program, commands, questions)) unless options[:all]
414
410
  out << " a command is called with its aggregate — #{example_qualified(commands)}"
415
411
  out.join("\n")
416
412
  end
417
413
 
414
+ # The commands table then the queries table.
415
+ def tables(commands, questions, all:, notes:)
416
+ ["commands:", *listing(commands, all: all, notes: notes) { |spec| spec[:summary] }, "",
417
+ "queries (nothing here changes anything):",
418
+ *listing(questions, all: all, notes: notes) { |spec| first_sentence(spec[:summary]) }]
419
+ end
420
+
421
+ # The line saying the internal commands and queries were left out and how to list them,
422
+ # or no line when there are none.
423
+ def all_hint(program, commands, questions)
424
+ hidden = (commands.values + questions.values).count { |spec| spec[:internal] }
425
+ return [] if hidden.zero?
426
+
427
+ [" #{program} --all also list the #{hidden} internal commands and queries"]
428
+ end
429
+
418
430
  # The command or question lines of the help. A domain with more than one aggregate is listed
419
431
  # under a heading per aggregate, so related commands sit together; the heading is the prefix
420
432
  # every call to them carries, so the lines under it leave it out. The bookkeeping a run
421
- # records about itself (`internal`: system-role commands and port operations) is set apart as
422
- # names only, since a person never types them. A single-aggregate domain keeps the plain
423
- # list, each name in full.
424
- def listing(specs)
433
+ # records about itself (`internal`: system-role commands and port operations) is left out,
434
+ # since a person never types them; `all` lists them as names only. A single-aggregate
435
+ # domain keeps the plain list, each name in full.
436
+ #
437
+ # @param notes [Hash{String => String}] a line of prose per aggregate, shown under its heading
438
+ def listing(specs, all: false, notes: {}, &description)
425
439
  shown, internal = specs.values.partition { |spec| !spec[:internal] }
426
- groups = shown.group_by { |spec| spec[:group] }
427
- grouped = groups.length > 1
440
+ grouped = shown.map { |spec| spec[:group] }.uniq.length > 1
428
441
  named = shown.to_h { |spec| [spec, entry_name(spec, grouped)] }
442
+ lines = listed_rows(shown, named, grouped, notes, &description)
443
+ lines.concat(internal_lines(internal)) if all && !internal.empty?
444
+ lines
445
+ end
446
+
447
+ # The rows of one table: one line per spec, under a heading per aggregate when grouped.
448
+ def listed_rows(shown, named, grouped, notes, &description)
429
449
  width = named.values.map(&:length).max.to_i
430
- lines = []
431
- if grouped
432
- groups.each do |group, members|
433
- lines << " #{heading(group)}" if group
434
- members.each { |spec| lines << " #{named[spec].ljust(width)} #{yield(spec)}#{alias_note(spec)}" }
435
- end
436
- else
437
- shown.each { |spec| lines << " #{named[spec].ljust(width)} #{yield(spec)}#{alias_note(spec)}" }
450
+ row = ->(spec, indent) { "#{indent}#{named[spec].ljust(width)} #{description.call(spec)}#{alias_note(spec)}" }
451
+ return shown.map { |spec| row.call(spec, " ") } unless grouped
452
+
453
+ shown.group_by { |spec| spec[:group] }.flat_map do |group, members|
454
+ [*heading_lines(group, notes), *members.map { |spec| row.call(spec, " ") }]
438
455
  end
439
- lines.concat(internal_lines(internal)) unless internal.empty?
440
- lines
456
+ end
457
+
458
+ # The lines that open an aggregate's group: its heading, then its note when it has one.
459
+ def heading_lines(group, notes)
460
+ return [] unless group
461
+
462
+ [" #{heading(group)}", *notes[group]&.then { |note| " #{note}" }]
463
+ end
464
+
465
+ # The first sentence of each aggregate's description, keyed by aggregate name; an aggregate
466
+ # with no description has no entry.
467
+ def aggregate_notes(bluebook)
468
+ bluebook.aggregates.to_h { |aggregate| [aggregate.hecks_name, first_sentence(aggregate.description)] }
469
+ .reject { |_, note| note.empty? }
441
470
  end
442
471
 
443
472
  # The aggregate's own name as a heading: the prefix of every call to the lines under it.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "shellwords"
5
+ require_relative "agent_profile"
5
6
 
6
7
  module Hecks
7
8
  module Adapters
@@ -27,10 +28,11 @@ module Hecks
27
28
  # The command an ask runs.
28
29
  #
29
30
  # @param command [String, Array<String>, nil] a command line, or its words
30
- # @return [Array<String>] `command` as words; `QA_MINER_AGENT` else `DEFAULT_COMMAND` when nil
31
- def command_for(command = nil)
31
+ # @param profile [AgentProfile, nil] when given, the default command takes its tools, budget
32
+ # @return [Array<String>] `command` as words; `QA_MINER_AGENT` else the default when nil
33
+ def command_for(command = nil, profile = nil)
32
34
  command ||= ENV.fetch("QA_MINER_AGENT", nil)
33
- return DEFAULT_COMMAND unless command
35
+ return default_command(profile) unless command
34
36
 
35
37
  command.is_a?(String) ? Shellwords.split(command) : command
36
38
  end
@@ -41,11 +43,13 @@ module Hecks
41
43
  # @param command [String, Array<String>, nil] the agent's command; see `command_for`
42
44
  # @param chdir [String] the directory the agent works in
43
45
  # @param log [String, nil] a file the agent's output is appended to
46
+ # @param profile [AgentProfile, nil] what the run may do; nil runs it as the caller could
44
47
  # @return [String] the agent's output
45
- # @raise [Failed] when the agent cannot start or exits non-zero
46
- def ask(prompt:, chdir:, command: nil, log: nil)
47
- words = command_for(command)
48
- output, status = Open3.capture2e(*words, stdin_data: prompt, chdir: chdir)
48
+ # @raise [Failed] when the agent cannot start, runs past its timeout or exits non-zero
49
+ def ask(prompt:, chdir:, command: nil, log: nil, profile: nil)
50
+ words = command_for(command, profile)
51
+ refuse_unconfinable!(words, profile)
52
+ output, status = run(words, prompt, chdir, profile)
49
53
  File.open(log, "a") { |file| file.puts(output) } if log
50
54
  return output if status.success?
51
55
 
@@ -53,6 +57,58 @@ module Hecks
53
57
  rescue SystemCallError => e
54
58
  raise Failed, "agent could not start (#{words.first}): #{e.message}"
55
59
  end
60
+
61
+ private
62
+
63
+ # Permission rules are a feature of `claude`: any other command would run unconfined.
64
+ def refuse_unconfinable!(words, profile)
65
+ return if profile.nil? || profile.sandboxed? || words == default_command(profile)
66
+
67
+ raise Failed, "permission confinement applies only to the default claude command"
68
+ end
69
+
70
+ def run(words, prompt, chdir, profile)
71
+ return run_confined(words, prompt, chdir, profile) if profile
72
+
73
+ Open3.capture2e(*words, stdin_data: prompt, chdir: chdir)
74
+ end
75
+
76
+ def default_command(profile)
77
+ return DEFAULT_COMMAND unless profile
78
+
79
+ %w[claude -p --permission-mode] + [profile.permission_mode] + profile.tool_flags
80
+ end
81
+
82
+ # Runs `words` under the profile's sandbox with only the environment it names, in its own
83
+ # process group so a timeout takes the agent's children with it.
84
+ def run_confined(words, prompt, chdir, profile)
85
+ raise Failed, "no sandbox here; refusing to run an agent unconfined" unless profile.available?
86
+
87
+ Open3.popen2e(profile.environment, *profile.confine(words),
88
+ chdir: chdir, unsetenv_others: true, pgroup: true) do |stdin, out, waiter|
89
+ feed(stdin, prompt)
90
+ reader = Thread.new { out.read }
91
+ finish(waiter, reader, profile.timeout)
92
+ end
93
+ end
94
+
95
+ def feed(stdin, prompt)
96
+ stdin.write(prompt)
97
+ rescue Errno::EPIPE
98
+ nil
99
+ ensure
100
+ stdin.close
101
+ end
102
+
103
+ def finish(waiter, reader, timeout)
104
+ unless waiter.join(timeout)
105
+ Process.kill("KILL", -waiter.pid)
106
+ waiter.join
107
+ reader.join
108
+ raise Failed, "agent timed out after #{timeout}s"
109
+ end
110
+ [reader.value, waiter.value]
111
+ end
56
112
  end
57
113
  end
58
114
  end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ # What one `Agent` run may do: which tools it holds, where it may write, what it may reach on
8
+ # the network, which environment variables it sees and how long it may run.
9
+ #
10
+ # Confinement uses the macOS sandbox. Where there is none, a confined run refuses to start
11
+ # rather than run unconfined.
12
+ class AgentProfile
13
+ # What the network rule lets through: nothing, outbound HTTPS and name lookups, or anything.
14
+ NETWORKS = %i[none https any].freeze
15
+
16
+ # How a run is confined: under the operating system's sandbox, or by the permission rules
17
+ # of the `claude` command itself. Only the second leaves the user's own login usable, since
18
+ # the sandbox refuses the keychain and `~/.claude`.
19
+ CONFINEMENTS = %i[sandbox permissions].freeze
20
+
21
+ # The variables every confined agent sees besides the ones its profile names.
22
+ BASE_ENV = %w[PATH HOME USER LOGNAME SHELL LANG LC_ALL TERM TMPDIR].freeze
23
+
24
+ # The tools that write files; a permissions-confined run holds them only through path rules.
25
+ WRITING_TOOLS = %w[Write Edit MultiEdit NotebookEdit].freeze
26
+
27
+ # Home-relative places an agent may not read: credentials and the harness's own state.
28
+ CREDENTIAL_PATHS = %w[
29
+ .ssh .aws .gnupg .claude .config/gh .config/op .gem/credentials .netrc Library/Keychains
30
+ ].freeze
31
+
32
+ SANDBOX = "/usr/bin/sandbox-exec"
33
+
34
+ # @return [Array<String>] the tools the agent is given
35
+ attr_reader :tools
36
+ # @return [Array<String>] the directories the agent may write, besides the temp directory
37
+ attr_reader :writable
38
+ # @return [Symbol] one of `NETWORKS`
39
+ attr_reader :network
40
+ # @return [Array<String>] the environment variable names the agent may see besides `BASE_ENV`
41
+ attr_reader :env
42
+ # @return [Integer, nil] seconds the run may take; nil for no limit
43
+ attr_reader :timeout
44
+ # @return [Float, nil] the most the run may spend, in dollars, for an agent that takes one
45
+ attr_reader :budget
46
+ # @return [Symbol] one of `CONFINEMENTS`
47
+ attr_reader :confinement
48
+
49
+ # @param tools [Array<String>] the tools the agent is given
50
+ # @param writable [Array<String>] directories the agent may write
51
+ # @param network [Symbol] one of `NETWORKS`
52
+ # @param env [Array<String>] environment variable names to pass through
53
+ # @param timeout [Integer, nil] seconds the run may take
54
+ # @param budget [Float, nil] dollars the run may spend
55
+ # @param confinement [Symbol] one of `CONFINEMENTS`; `network` applies to the sandbox only
56
+ # @raise [ArgumentError] when `network` or `confinement` is not one it knows
57
+ def initialize(tools: [], writable: [], network: :none, env: [], timeout: nil, budget: nil,
58
+ confinement: :sandbox)
59
+ unless NETWORKS.include?(network)
60
+ raise ArgumentError, "network must be one of #{NETWORKS.inspect}, not #{network.inspect}"
61
+ end
62
+ unless CONFINEMENTS.include?(confinement)
63
+ raise ArgumentError, "confinement must be one of #{CONFINEMENTS.inspect}, not #{confinement.inspect}"
64
+ end
65
+
66
+ @tools = tools.dup.freeze
67
+ @writable = writable.dup.freeze
68
+ @network = network
69
+ @env = env.dup.freeze
70
+ @timeout = timeout
71
+ @budget = budget
72
+ @confinement = confinement
73
+ end
74
+
75
+ # @return [Boolean] whether this profile is enforced by the operating system's sandbox
76
+ def sandboxed?
77
+ confinement == :sandbox
78
+ end
79
+
80
+ # @return [Boolean] whether this machine can apply this profile's confinement
81
+ def available?
82
+ !sandboxed? || File.executable?(SANDBOX)
83
+ end
84
+
85
+ # @param words [Array<String>] the agent's command
86
+ # @return [Array<String>] `words`, run under the sandbox with this profile's policy; the
87
+ # words unchanged when the confinement is the command's own permission rules
88
+ def confine(words)
89
+ sandboxed? ? [SANDBOX, "-p", policy, *words] : words
90
+ end
91
+
92
+ # @return [String] the `claude` permission mode this profile runs under
93
+ def permission_mode
94
+ sandboxed? ? "acceptEdits" : "dontAsk"
95
+ end
96
+
97
+ # @param source [Hash{String => String}] the environment to take values from
98
+ # @return [Hash{String => String}] only the variables this profile lets the agent see
99
+ def environment(source = ENV)
100
+ source.to_h.slice(*BASE_ENV, *env)
101
+ end
102
+
103
+ # The `claude` flags this profile's tools and budget become.
104
+ #
105
+ # @return [Array<String>]
106
+ def tool_flags
107
+ flags = tools.empty? ? [] : ["--tools", tools.join(","), "--allowedTools", *allowed_tools]
108
+ flags += ["--strict-mcp-config"] unless sandboxed?
109
+ flags + (budget ? ["--max-budget-usd", budget.to_s] : [])
110
+ end
111
+
112
+ # @return [String] the sandbox policy: reads open but for credentials, writes where allowed
113
+ def policy
114
+ [
115
+ "(version 1)", "(allow default)",
116
+ "(deny file-write*)", write_rule, "(deny file-read*#{paths_clause(credential_paths)})",
117
+ *network_rules
118
+ ].join("\n")
119
+ end
120
+
121
+ private
122
+
123
+ # The tools allowed without asking. A permissions-confined run holds the writing tools only
124
+ # through an `Edit` rule per writable directory, which every file-writing tool answers to.
125
+ def allowed_tools
126
+ return [tools.join(",")] if sandboxed?
127
+
128
+ tools - WRITING_TOOLS + writable.map { |path| "Edit(/#{real(path)}/**)" }
129
+ end
130
+
131
+ def write_rule
132
+ allowed = [real(Dir.tmpdir), *writable.map { |path| real(path) }]
133
+ devices = '(literal "/dev/null") (literal "/dev/tty") (regex #"^/dev/fd/")'
134
+ "(allow file-write*#{paths_clause(allowed)} #{devices})"
135
+ end
136
+
137
+ def credential_paths
138
+ CREDENTIAL_PATHS.map { |path| File.join(Dir.home, path) }
139
+ end
140
+
141
+ def paths_clause(paths)
142
+ paths.map { |path| " (subpath #{quote(path)})" }.join
143
+ end
144
+
145
+ def network_rules
146
+ case network
147
+ when :any then []
148
+ when :https then ["(deny network*)", '(allow network-outbound (remote tcp "*:443") (remote udp "*:53"))',
149
+ '(allow network-outbound (remote unix-socket (path-literal "/private/var/run/mDNSResponder")))']
150
+ else ["(deny network*)"]
151
+ end
152
+ end
153
+
154
+ def real(path)
155
+ File.realpath(path)
156
+ rescue SystemCallError
157
+ File.expand_path(path)
158
+ end
159
+
160
+ def quote(path)
161
+ %("#{path.gsub('\\', '\\\\\\\\').gsub('"', '\\"')}")
162
+ end
163
+ end
164
+ end
165
+ end
@@ -16,23 +16,28 @@ module Hecks
16
16
  # The command behind `hecks quality_control target.mine_combinations`: asks an agent to mine the
17
17
  # adversarial corpus for new domain combinations, then checks them. It is opt-in and never run
18
18
  # by `hecks quality_control sweep.tick` (an agent call costs money and answers differently each
19
- # run).
20
- # `--brief` prints the agent's prompt and stops; `--from <dir>` re-checks an earlier run;
21
- # `--against <domain>` narrows the census; `--agent "cmd"` reads the prompt on stdin.
19
+ # run). `--brief` prints the agent's prompt and stops; `--from <dir>` re-checks an earlier run;
20
+ # `--against <domain>` narrows the census; `--agent "cmd"` reads the prompt on stdin;
21
+ # `--confine` limits the default agent to writing the run's candidates directory.
22
22
  #
23
23
  # The agent defaults to `claude -p` (`QA_MINER_AGENT` overrides); candidates that boot are
24
24
  # checked by `hecks quality_control target.check_generated_domains --source`, whose report is
25
25
  # this command's report. Exit 0: all clean. 2: a finding (see
26
- # `.claude/skills/hecks_qa/SKILL.md`). 1: an operational
27
- # error.
26
+ # `.claude/skills/hecks_qa/SKILL.md`). 1: an operational error.
28
27
  class QaMineCombinations
29
28
  EXIT_OK = 0
30
29
  EXIT_ERROR = 1
31
30
  RESULT_MARKER = "QA_GENERATED_RESULT "
32
31
 
32
+ # What `--confine` lets the default agent do: read anything, write the candidates directory,
33
+ # run for twenty minutes and spend two dollars.
34
+ CONFINED_TOOLS = %w[Read Glob Grep Write Edit].freeze
35
+ CONFINED_TIMEOUT = 1200
36
+ CONFINED_BUDGET = 2.0
37
+
33
38
  USAGE = "usage: hecks quality_control mine_combinations [--candidates N] [--rust] [--seeds K] " \
34
39
  "[--steps M] " \
35
- "[--adversarial F] [--repair-rounds R] [--agent CMD] [--from <candidates-dir>] " \
40
+ "[--adversarial F] [--repair-rounds R] [--agent CMD] [--confine] [--from <candidates-dir>] " \
36
41
  "[--against <domain> …] [--brief]"
37
42
 
38
43
  Miner = Hecks::Fuzzing::CombinationMiner
@@ -65,7 +70,8 @@ module Hecks
65
70
  @out_dir = @options[:from] || File.join(@run_dir, "candidates")
66
71
  @agent_log = File.join(@run_dir, "agent.log")
67
72
  @agent = Hecks::Adapters::Agent.new
68
- @command = @agent.command_for(@options[:agent])
73
+ @profile = confinement_profile if @options[:confine]
74
+ @command = @agent.command_for(@options[:agent], @profile)
69
75
  corpus = @options[:against].empty? ? Miner.corpus_paths(@root) : @options[:against]
70
76
  @brief = Miner.brief(corpus, root: @root, bug_titles: Miner.recent_bug_titles(@root))
71
77
  prompt = Miner.prompt(@root, @brief, count: @options[:candidates], out_dir: @out_dir)
@@ -89,7 +95,7 @@ module Hecks
89
95
 
90
96
  def parse(argv)
91
97
  options = { candidates: 3, rust: false, seeds: 5, steps: 25, adversarial: 0.3, repair_rounds: 1,
92
- agent: nil, from: nil, brief: false, against: [] }
98
+ agent: nil, from: nil, brief: false, confine: false, against: [] }
93
99
  until argv.empty?
94
100
  arg = argv.shift
95
101
  if %w[-h --help].include?(arg)
@@ -108,6 +114,7 @@ module Hecks
108
114
  options[key] = reader.call(argv.shift)
109
115
  when "--rust" then options[:rust] = true
110
116
  when "--brief" then options[:brief] = true
117
+ when "--confine" then options[:confine] = true
111
118
  when "--against" then options[:against] << File.expand_path(argv.shift)
112
119
  else abort "#{USAGE}\nunexpected argument: #{arg.inspect}"
113
120
  end
@@ -124,12 +131,19 @@ module Hecks
124
131
 
125
132
  # Returns nil when the agent finished, else why it did not.
126
133
  def ask_agent(prompt)
127
- @agent.ask(prompt: prompt, command: @command, chdir: @root, log: @agent_log)
134
+ @agent.ask(prompt: prompt, command: @command, chdir: @root, log: @agent_log, profile: @profile)
128
135
  nil
129
136
  rescue Hecks::Adapters::Agent::Failed => e
130
137
  e.message
131
138
  end
132
139
 
140
+ # The profile `--confine` runs the agent under: it writes only the candidates directory.
141
+ def confinement_profile
142
+ FileUtils.mkdir_p(@out_dir)
143
+ Hecks::Adapters::AgentProfile.new(confinement: :permissions, tools: CONFINED_TOOLS, writable: [@out_dir],
144
+ timeout: CONFINED_TIMEOUT, budget: CONFINED_BUDGET)
145
+ end
146
+
133
147
  def boot_error(candidate, boot_root)
134
148
  dir = Hecks::Fuzzing::DomainGenerator.write(
135
149
  { "source" => File.read(candidate[:bluebook]), "aggregates" => [], "policies" => [] },
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.1.1".freeze
4
+ VERSION = "3.1.3".freeze
5
5
  end
@@ -444,14 +444,7 @@ pub fn emit_command(exemplar: &Exemplar, command: &Json, aggregate: &Json, domai
444
444
  .collect();
445
445
 
446
446
  let transition = mutations::lifecycle_transition_for(command, aggregate);
447
- let transition_arg = match &transition {
448
- Some(t) => format!(
449
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
450
- naming::ruby_inspect_string(&t.field),
451
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
452
- ),
453
- None => "None".to_string(),
454
- };
447
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
455
448
 
456
449
  let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
457
450
  // A `delegate` mutation is rendered by `delegation_of`, which overwrites `mutation_lines`;
@@ -772,14 +765,7 @@ fn delegation_of(exemplar: &Exemplar, command: &Json, aggregate: &Json, value_ob
772
765
  })
773
766
  .collect();
774
767
  let transition = mutations::lifecycle_transition_for(target, entity);
775
- let transition_arg = match &transition {
776
- Some(t) => format!(
777
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
778
- naming::ruby_inspect_string(&t.field),
779
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
780
- ),
781
- None => "None".to_string(),
782
- };
768
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
783
769
  let mut mutation_lines: Vec<String> = target
784
770
  .get("mutations")
785
771
  .map(Json::each)
@@ -896,14 +882,7 @@ pub fn emit_entity_command(
896
882
  .collect();
897
883
 
898
884
  let transition = mutations::lifecycle_transition_for(command, entity);
899
- let transition_arg = match &transition {
900
- Some(t) => format!(
901
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
902
- naming::ruby_inspect_string(&t.field),
903
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
904
- ),
905
- None => "None".to_string(),
906
- };
885
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
907
886
 
908
887
  let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
909
888
  let mut mutation_lines: Vec<String> = mutations_list.iter().map(|m| mutations::emit_mutation_line(exemplar, m, entity, command, value_objects_by_name, false)).collect();
@@ -1063,14 +1042,7 @@ pub fn emit_nested_entity_command(
1063
1042
 
1064
1043
  // The nested entity's lifecycle, as in `emit_entity_command`.
1065
1044
  let transition = mutations::lifecycle_transition_for(command, nested);
1066
- let transition_arg = match &transition {
1067
- Some(t) => format!(
1068
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
1069
- naming::ruby_inspect_string(&t.field),
1070
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
1071
- ),
1072
- None => "None".to_string(),
1073
- };
1045
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
1074
1046
 
1075
1047
  let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
1076
1048
  let mut mutation_lines: Vec<String> = mutations_list.iter().map(|m| mutations::emit_mutation_line(exemplar, m, nested, command, value_objects_by_name, false)).collect();
@@ -392,6 +392,23 @@ pub struct Transition {
392
392
  pub field: String,
393
393
  pub to_state: String,
394
394
  pub from_states: Vec<String>,
395
+ /// A row with no `from:` admits every state, so no check is emitted for the command.
396
+ pub unconstrained: bool,
397
+ }
398
+
399
+ /// The `Option<TransitionCheck>` expression a dispatch function is generated with.
400
+ ///
401
+ /// `None` covers both "no transition" and an unconstrained one; an empty `from_states` slice
402
+ /// would refuse every state instead of admitting every state.
403
+ pub fn transition_check_arg(transition: Option<&Transition>) -> String {
404
+ match transition {
405
+ Some(t) if !t.unconstrained => format!(
406
+ "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
407
+ naming::ruby_inspect_string(&t.field),
408
+ t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
409
+ ),
410
+ _ => "None".to_string(),
411
+ }
395
412
  }
396
413
 
397
414
  /// Collapses a command's transition rows into the shape `TransitionCheck` wants.
@@ -417,19 +434,25 @@ pub fn lifecycle_transition_for(command: &Json, aggregate: &Json) -> Option<Tran
417
434
  }
418
435
  }
419
436
  let field = lifecycle.get("field").and_then(Json::as_str).unwrap_or("").to_string();
420
- return Some(Transition { field, to_state: String::new(), from_states });
437
+ return Some(Transition { field, to_state: String::new(), from_states, unconstrained: false });
421
438
  }
422
439
 
423
440
  let field = lifecycle.get("field").and_then(Json::as_str).unwrap_or("").to_string();
424
441
  let to_state = rows[0].get("to_state").map(Json::to_s).unwrap_or_default();
425
442
  let mut from_states: Vec<String> = Vec::new();
443
+ let mut unconstrained = false;
426
444
  for row in &rows {
427
- let from = row.get("from_state").map(Json::to_s).unwrap_or_default();
428
- if !from_states.contains(&from) {
429
- from_states.push(from);
445
+ match row.get_raw("from_state") {
446
+ None | Some(Json::Null) => unconstrained = true,
447
+ Some(from) => {
448
+ let from = from.to_s();
449
+ if !from_states.contains(&from) {
450
+ from_states.push(from);
451
+ }
452
+ }
430
453
  }
431
454
  }
432
- Some(Transition { field, to_state, from_states })
455
+ Some(Transition { field, to_state, from_states, unconstrained })
433
456
  }
434
457
 
435
458
  /// Right-hand side for a `set`, coercing the source into the target attribute's declared type.
@@ -782,3 +805,49 @@ fn emit_mutation_line_body(
782
805
  other => panic!("unsupported mutation op {other:?} — command_skip_reason should have caught this"),
783
806
  }
784
807
  }
808
+
809
+ #[cfg(test)]
810
+ mod transition_tests {
811
+ use super::*;
812
+
813
+ fn aggregate(rows: &str) -> Json {
814
+ Json::parse(&format!(
815
+ r#"{{"name":"Account","lifecycle":{{"field":"status","default":"draft","transitions":[{rows}]}}}}"#
816
+ ))
817
+ .unwrap()
818
+ }
819
+
820
+ fn command(name: &str) -> Json {
821
+ Json::parse(&format!(r#"{{"name":"{name}"}}"#)).unwrap()
822
+ }
823
+
824
+ #[test]
825
+ fn a_transition_without_from_admits_every_state() {
826
+ let account = aggregate(r#"{"command":"Open","to_state":"open","from_state":null}"#);
827
+ let transition = lifecycle_transition_for(&command("Open"), &account).unwrap();
828
+ assert!(transition.unconstrained);
829
+ assert_eq!(transition.to_state, "open");
830
+ assert_eq!(transition_check_arg(Some(&transition)), "None");
831
+ }
832
+
833
+ #[test]
834
+ fn a_transition_with_from_checks_that_state() {
835
+ let account = aggregate(r#"{"command":"Close","to_state":"closed","from_state":"open"}"#);
836
+ let transition = lifecycle_transition_for(&command("Close"), &account).unwrap();
837
+ assert!(!transition.unconstrained);
838
+ assert_eq!(
839
+ transition_check_arg(Some(&transition)),
840
+ r#"Some(crate::kernel::TransitionCheck { field: "status", from_states: &["open"] })"#
841
+ );
842
+ }
843
+
844
+ #[test]
845
+ fn one_unconstrained_row_among_constrained_ones_admits_every_state() {
846
+ let account = aggregate(
847
+ r#"{"command":"Reopen","to_state":"open","from_state":"closed"},
848
+ {"command":"Reopen","to_state":"open","from_state":null}"#,
849
+ );
850
+ let transition = lifecycle_transition_for(&command("Reopen"), &account).unwrap();
851
+ assert_eq!(transition_check_arg(Some(&transition)), "None");
852
+ }
853
+ }
@@ -1 +1 @@
1
- 3.1.1
1
+ 3.1.3
@@ -0,0 +1,39 @@
1
+ use super::*;
2
+
3
+ #[test]
4
+ fn cents_render_as_dollars_with_two_places() {
5
+ assert_eq!(dollars(10800), "$108.00");
6
+ assert_eq!(dollars(14805), "$148.05");
7
+ assert_eq!(dollars(5), "$0.05");
8
+ }
9
+
10
+ #[test]
11
+ fn the_built_in_receipt_names_the_event_the_first_name_and_the_amount() {
12
+ let body = built_in_body(Some("Ada"), "Yogadelics", Some(10800));
13
+ assert!(body.starts_with("Hi Ada,"));
14
+ assert!(body.contains("You're registered for Yogadelics."));
15
+ assert!(body.contains("$108.00"));
16
+ assert_eq!(receipt_subject("Yogadelics"), "You're registered for Yogadelics");
17
+ }
18
+
19
+ #[test]
20
+ fn a_built_in_receipt_without_a_name_or_amount_still_reads_cleanly() {
21
+ let body = built_in_body(None, "Yogadelics", None);
22
+ assert!(body.starts_with("Hi,"));
23
+ assert!(!body.contains("payment of"));
24
+ }
25
+
26
+ #[test]
27
+ fn the_sites_finished_email_is_read() {
28
+ let answer = serde_json::json!({"subject": "You're registered for Yogadelics", "body": "Hi Ada,\n\nWhere: Somewhere\n"});
29
+ assert_eq!(
30
+ site_email_from_json(&answer),
31
+ Some(SiteEmail { subject: "You're registered for Yogadelics".into(), body: "Hi Ada,\n\nWhere: Somewhere\n".into() })
32
+ );
33
+ }
34
+
35
+ #[test]
36
+ fn an_incomplete_answer_from_the_site_falls_back_to_the_built_in_wording() {
37
+ assert_eq!(site_email_from_json(&serde_json::json!({"error": "no such session"})), None);
38
+ assert_eq!(site_email_from_json(&serde_json::json!({"subject": "Hi", "body": " "})), None);
39
+ }