hecks 3.0.4 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +25 -7
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/lambda.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
- data/lib/hecks/bluebook/hexagon.rb +45 -25
- data/lib/hecks/bluebook/model_check.rb +7 -7
- data/lib/hecks/chapters.rb +31 -3
- data/lib/hecks/cli/check_era.rb +1 -1
- data/lib/hecks/cli/console.rb +24 -8
- data/lib/hecks/cli/deploy_template_diff.rb +1 -1
- data/lib/hecks/cli/doc_coverage.rb +1 -1
- data/lib/hecks/cli/domain_stub.rb +128 -0
- data/lib/hecks/cli/interview_draft.rb +246 -0
- data/lib/hecks/cli/model_check.rb +1 -1
- data/lib/hecks/cli/project_cli.rb +69 -18
- data/lib/hecks/cli/reference.rb +2 -1
- data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
- data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
- data/lib/hecks/cli/spec_example.rb +1 -1
- data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
- data/lib/hecks/corpus.rb +11 -8
- data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
- data/lib/hecks/deploy/oidc.json +5 -0
- data/lib/hecks/doc/reference.rb +4 -3
- data/lib/hecks/doors/cli_door.rb +13 -13
- data/lib/hecks/doors/cli_runner.rb +52 -32
- data/lib/hecks/doors/launcher_options.rb +23 -5
- data/lib/hecks/doors/mcp_door.rb +40 -18
- data/lib/hecks/doors/mcp_door_scope.rb +244 -24
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -2
- data/lib/hecks/forms/banking_presentation.rb +1 -1
- data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
- data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
- data/lib/hecks/framework.rb +1 -1
- data/lib/hecks/fuzzing/combination_miner.rb +1 -1
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
- data/lib/hecks/fuzzing/differential.rb +4 -2
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/form_census.rb +2 -2
- data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
- data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
- data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
- data/lib/hecks/gate/stages.yml +8 -4
- data/lib/hecks/grammar/grammar.hecksagon +2 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
- data/lib/hecks/hecks/adapters/host_http.rb +4 -3
- data/lib/hecks/hecks/adapters/local_files.rb +59 -0
- data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
- data/lib/hecks/hecks/codebase.bluebook +4 -3
- data/lib/hecks/hecks/context_map.hecksagon +2 -2
- data/lib/hecks/hecks/custodian.bluebook +45 -3
- data/lib/hecks/hecks/hecks.hecksagon +10 -4
- data/lib/hecks/hecks/hecks.world +7 -4
- data/lib/hecks/hecks/site.behaviors +17 -0
- data/lib/hecks/hecks/site.world +10 -0
- data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
- data/lib/hecks/projections/deploy/box/settings.rb +248 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
- data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
- data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
- data/lib/hecks/projections/deploy/box.rb +387 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
- data/lib/hecks/projections/deploy/fargate.rb +1 -1
- data/lib/hecks/projections/deploy/lambda.rb +11 -10
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
- data/lib/hecks/projections/diagrams.rb +2 -2
- data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
- data/lib/hecks/projections/site/edge/checks.rb +137 -0
- data/lib/hecks/projections/site/edge/pattern.rb +50 -0
- data/lib/hecks/projections/site/edge/rules.rb +128 -0
- data/lib/hecks/projections/site/edge.rb +136 -0
- data/lib/hecks/projections/site/regions.rb +44 -0
- data/lib/hecks/projections/site/routes_ts.rb +284 -0
- data/lib/hecks/projections/site/site_cdn.rb +78 -0
- data/lib/hecks/projections/site/table/checks.rb +124 -0
- data/lib/hecks/projections/site/table/row_builder.rb +155 -0
- data/lib/hecks/projections/site/table.rb +98 -0
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/projector/cli_projector.rb +172 -95
- data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
- data/lib/hecks/quality_control/adapters/agent.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
- data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
- data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
- data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
- data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
- data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
- data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
- data/lib/hecks/quality_control/quality_control.bluebook +18 -18
- data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +2 -0
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/invocation.rb +5 -3
- data/lib/hecks/runtime/registry/verification.rb +15 -29
- data/lib/hecks/runtime/registry.rb +14 -20
- data/lib/hecks/rust_build/append_optionals.rb +79 -0
- data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
- data/lib/hecks/rust_build/domain_name.rb +26 -0
- data/lib/hecks/rust_build/kernel_input.rb +58 -0
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
- data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
- data/lib/hecks/rust_build/project_rust.rb +57 -24
- data/lib/hecks/rust_build/rust_literal.rb +33 -0
- data/lib/hecks/rust_build/write_if_changed.rb +86 -0
- data/lib/hecks/site/bluebook/site.bluebook +205 -0
- data/lib/hecks/site/bluebook/site.hecksagon +5 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
- data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
- data/lib/hecks/storehouse.rb +37 -7
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
- data/lib/hecks/three_zero/forms.yml +88 -88
- data/lib/hecks/tools/ci_gates.rb +1 -1
- data/lib/hecks/tools/deploy_recipe.rb +14 -6
- data/lib/hecks/tools/oidc_manifests.rb +2 -1
- data/lib/hecks/tools/sequence_script.rb +2 -2
- data/lib/hecks/tools/site_routes.rb +152 -0
- data/lib/hecks/tools/tenant_provisioning.rb +1 -1
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/rust/build/src/cargo_sync.rs +1 -1
- data/rust/build/src/pipeline.rs +23 -16
- data/rust/build/src/resolve.rs +39 -6
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +87 -7
- data/rust/host/src/needs.rs +111 -0
- data/rust/host/src/web/newsletter/template.rs +69 -0
- data/rust/host/src/web/newsletter/tests.rs +43 -0
- data/rust/host/src/web/newsletter.rs +6 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/parser/src/keywords.rs +7 -6
- data/rust/parser/src/main.rs +4 -3
- data/rust/parser/src/parse/chapter.rs +1 -1
- data/rust/parser/src/parse/hecksagon.rs +25 -10
- data/rust/project/mutations.rb +4 -30
- data/rust/project/naming.rb +8 -28
- data/rust/project/write_if_changed.rb +4 -80
- data/rust/project_rust_pipeline.rb +16 -12
- data/rust/src/kernel/cli.rs +2 -0
- data/rust/src/kernel/needs.rs +74 -1
- metadata +39 -2
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
require_relative "../naming"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module CLI
|
|
5
|
+
# The files `hecks init` writes: a stub domain that boots as it stands and that the author
|
|
6
|
+
# then edits into a real one (ADR 0087).
|
|
7
|
+
#
|
|
8
|
+
# Pure: it answers the text of each file and writes none, so the adapter that does the writing
|
|
9
|
+
# can check every target before it writes the first, and a spec can boot the result.
|
|
10
|
+
module DomainStub
|
|
11
|
+
# A domain's name: a capital, then letters and digits, as its bluebook spells it.
|
|
12
|
+
NAME = /\A[A-Z][A-Za-z0-9]*\z/
|
|
13
|
+
|
|
14
|
+
SQLITE_URL = 'default_database "data/%<snake>s.db"'.freeze
|
|
15
|
+
POSTGRES_URL = 'default_database "postgres://localhost/%<snake>s"'.freeze
|
|
16
|
+
private_constant :SQLITE_URL, :POSTGRES_URL
|
|
17
|
+
|
|
18
|
+
# Persistence adapters a stub can be bound to, each with the world lines that bind it, the
|
|
19
|
+
# data it keeps under `data/` (so a `.gitignore` is written), and whether it needs a server
|
|
20
|
+
# (so a memory overlay is written and the first run needs no database).
|
|
21
|
+
ADAPTERS = {
|
|
22
|
+
"Memory" => { world: ['default_adapter "Memory"'], local_data: false, server: false },
|
|
23
|
+
"SqlitePersistence" => { world: ['default_adapter "SqlitePersistence"', SQLITE_URL],
|
|
24
|
+
local_data: true, server: false },
|
|
25
|
+
"Heki" => { world: ['default_adapter "Heki"', 'persisted_by("Heki") { dir "data" }'],
|
|
26
|
+
local_data: true, server: false },
|
|
27
|
+
"Postgres" => { world: ['default_adapter "Postgres"', POSTGRES_URL],
|
|
28
|
+
local_data: false, server: true },
|
|
29
|
+
"PostgresEra" => { world: ['default_adapter "PostgresEra"', POSTGRES_URL],
|
|
30
|
+
local_data: false, server: true }
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# What `init` binds a stub to when no adapter is named: it needs no server and keeps its data.
|
|
34
|
+
DEFAULT_ADAPTER = "SqlitePersistence".freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @return [Array<String>] the adapter names `init` accepts
|
|
39
|
+
def adapters = ADAPTERS.keys
|
|
40
|
+
|
|
41
|
+
# @param name [String] the domain's name, as its bluebook spells it (`Lending`)
|
|
42
|
+
# @param adapter [String, nil] an adapter named in `ADAPTERS`; the default when nil
|
|
43
|
+
# @return [Hash{String => String}] each file's path under the domain directory, and its text
|
|
44
|
+
# @raise [ArgumentError] when the name is not a capitalised word or the adapter is unknown
|
|
45
|
+
def files(name:, adapter: nil)
|
|
46
|
+
adapter ||= DEFAULT_ADAPTER
|
|
47
|
+
check!(name, adapter)
|
|
48
|
+
{ "bluebook/#{Naming.snake(name)}.bluebook" => bluebook(name) }.merge(support_files(name: name, adapter: adapter))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The files around a domain's bluebook: its world bound to the adapter, the memory overlay for
|
|
52
|
+
# an adapter that needs a server, and a `.gitignore` for one that keeps data. `hecks init`
|
|
53
|
+
# writes them beside a stub, and an interview draft beside the bluebook it renders (ADR 0088).
|
|
54
|
+
#
|
|
55
|
+
# @param name [String] the domain's name
|
|
56
|
+
# @param adapter [String, nil] an adapter named in `ADAPTERS`; the default when nil
|
|
57
|
+
# @return [Hash{String => String}] each file's path under the domain directory, and its text
|
|
58
|
+
# @raise [ArgumentError] when the name is not a capitalised word or the adapter is unknown
|
|
59
|
+
def support_files(name:, adapter: nil)
|
|
60
|
+
adapter ||= DEFAULT_ADAPTER
|
|
61
|
+
check!(name, adapter)
|
|
62
|
+
snake = Naming.snake(name)
|
|
63
|
+
files = { "bluebook/#{snake}.world" => world(name, snake, adapter) }
|
|
64
|
+
files["bluebook/environments/memory.world"] = overlay(name) if ADAPTERS.fetch(adapter).fetch(:server)
|
|
65
|
+
files[".gitignore"] = "data/\n" if ADAPTERS.fetch(adapter).fetch(:local_data)
|
|
66
|
+
files
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param name [String] a domain name
|
|
70
|
+
# @return [String] the directory `init` writes into when none is named
|
|
71
|
+
def directory(name) = Naming.snake(name)
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
74
|
+
def check!(name, adapter)
|
|
75
|
+
unless name.to_s.match?(NAME)
|
|
76
|
+
raise ArgumentError, "#{name.inspect} is not a domain name: a capital, then letters and digits (Lending)"
|
|
77
|
+
end
|
|
78
|
+
return if ADAPTERS.key?(adapter)
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "unknown adapter #{adapter.inspect}; choose one of #{adapters.join(', ')}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @api private
|
|
84
|
+
def bluebook(name)
|
|
85
|
+
<<~BLUEBOOK
|
|
86
|
+
Hecks.bluebook "#{name}" do
|
|
87
|
+
vision "TODO: say in one sentence what #{name} is for."
|
|
88
|
+
supporting
|
|
89
|
+
|
|
90
|
+
# TODO: rename Example to the main thing #{name} keeps track of.
|
|
91
|
+
aggregate "Example" do
|
|
92
|
+
description "TODO: describe what an Example is."
|
|
93
|
+
|
|
94
|
+
identified_by :name
|
|
95
|
+
|
|
96
|
+
attribute :name, ExampleName
|
|
97
|
+
|
|
98
|
+
value_object "ExampleName" do
|
|
99
|
+
attribute :value, String, pattern: '[^ \\t\\n\\r]'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# TODO: replace Create with the first thing that happens to an Example.
|
|
103
|
+
command "Create" do
|
|
104
|
+
goal "TODO: say what Create does"
|
|
105
|
+
|
|
106
|
+
attribute :name, ExampleName
|
|
107
|
+
|
|
108
|
+
emits "ExampleCreated"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
BLUEBOOK
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @api private
|
|
116
|
+
def world(name, snake, adapter)
|
|
117
|
+
lines = ADAPTERS.fetch(adapter).fetch(:world).map { |line| " #{format(line, snake: snake)}" }
|
|
118
|
+
note = adapter == "PostgresEra" ? " # PostgresEra refuses a superuser role; use an ordinary one in the URL.\n" : ""
|
|
119
|
+
"Hecks.world \"#{name}\" do\n realm \"#{name}\"\n#{note}#{lines.join("\n")}\nend\n"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @api private
|
|
123
|
+
def overlay(name)
|
|
124
|
+
"Hecks.world \"#{name}\" do\n default_adapter \"Memory\"\nend\n"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
require_relative "../naming"
|
|
2
|
+
require_relative "domain_stub"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module CLI
|
|
6
|
+
# Turns an interview's accepted findings into the files of a first domain (ADR 0088).
|
|
7
|
+
#
|
|
8
|
+
# Pure: it answers the text of each file and writes none, so the command that runs an interview
|
|
9
|
+
# can check every target before it writes the first, and a spec can boot the result. It shares
|
|
10
|
+
# the files around a bluebook with `hecks init` (`DomainStub.support_files`) and renders the
|
|
11
|
+
# bluebook itself, from what the developer accepted, where `init` writes a fixed stub.
|
|
12
|
+
#
|
|
13
|
+
# Only `accepted` findings reach the bluebook; every finding reaches the record. A rule is never
|
|
14
|
+
# turned into code from prose: it is a comment citing its source, to become a `given` by hand.
|
|
15
|
+
module InterviewDraft
|
|
16
|
+
ACCEPTED = "accepted".freeze
|
|
17
|
+
|
|
18
|
+
# The pattern every generated string field carries: it refuses a blank value.
|
|
19
|
+
PATTERN = %q('[^ \t\n\r]').freeze
|
|
20
|
+
|
|
21
|
+
# What stands in for a creating command nothing in the interview named.
|
|
22
|
+
STUB_NOTE = " # TODO: no action that creates was accepted; replace Create with the real one.".freeze
|
|
23
|
+
private_constant :PATTERN, :STUB_NOTE
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# The files a first interview writes: the bluebook, the files around it, and the record.
|
|
28
|
+
#
|
|
29
|
+
# @param interview [Hash] the interview, with at least one accepted thing
|
|
30
|
+
# @param adapter [String, nil] an adapter in `DomainStub::ADAPTERS`; its default when nil
|
|
31
|
+
# @return [Hash{String => String}] each file's path under the domain directory, and its text
|
|
32
|
+
# @raise [ArgumentError] when nothing was accepted, or the subject or adapter is refused
|
|
33
|
+
def files(interview, adapter: nil)
|
|
34
|
+
name = interview.fetch(:subject)
|
|
35
|
+
support = DomainStub.support_files(name: name, adapter: adapter)
|
|
36
|
+
{ "bluebook/#{Naming.snake(name)}.bluebook" => bluebook(interview) }
|
|
37
|
+
.merge(support)
|
|
38
|
+
.merge(record_file(interview))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# What a later interview writes: its record, ending in the aggregates and commands it would
|
|
42
|
+
# add, for a developer to merge. An existing domain is never touched (ADR 0088).
|
|
43
|
+
#
|
|
44
|
+
# @param interview [Hash] the interview
|
|
45
|
+
# @return [Hash{String => String}] the one file, `interviews/<reference>.md`
|
|
46
|
+
def additions(interview)
|
|
47
|
+
record_file(interview, "\n## Proposed additions\n\nMerge by hand into the domain's bluebook.\n\n" \
|
|
48
|
+
"```ruby\n#{fragment(interview)}```\n")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The interview's record: every exchange in order, and every finding with how it was decided.
|
|
52
|
+
#
|
|
53
|
+
# @param interview [Hash] the interview
|
|
54
|
+
# @return [String] Markdown
|
|
55
|
+
def record(interview)
|
|
56
|
+
lines = ["# Interview #{interview.fetch(:reference)}: #{interview.fetch(:subject)}", "",
|
|
57
|
+
"Expert: #{interview.fetch(:expert)}", "", "## Exchanges", ""]
|
|
58
|
+
interview.fetch(:exchanges).each_with_index { |ex, i| lines.concat(exchange_lines(ex, i + 1)) }
|
|
59
|
+
lines << "" << "## Findings" << ""
|
|
60
|
+
lines.concat(finding_lines(interview))
|
|
61
|
+
"#{lines.join("\n")}\n"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Reads the plain interview out of an `Interview` record booted from the SME chapter.
|
|
65
|
+
#
|
|
66
|
+
# @param interview [Object] the booted record
|
|
67
|
+
# @return [Hash] the plain interview this module takes: `reference`, `subject`, `expert`,
|
|
68
|
+
# `exchanges` (`question`, `answer`, `topic`), and `things`, `actions` and `rules`, each a
|
|
69
|
+
# finding with its `number`, `source` and `status` and the fields of its kind
|
|
70
|
+
def from_record(interview)
|
|
71
|
+
{ reference: plain(interview.reference), subject: plain(interview.subject), expert: plain(interview.expert),
|
|
72
|
+
exchanges: interview.exchanges.map { |ex| slice(ex, :question, :answer, :topic) },
|
|
73
|
+
things: interview.thing_findings.map { |f| slice(f, :number, :name, :identifier, :source, :status) },
|
|
74
|
+
actions: interview.action_findings.map { |f| slice(f, :number, :name, :thing, :event, :creates, :source, :status) },
|
|
75
|
+
rules: interview.rule_findings.map { |f| slice(f, :number, :statement, :source, :status) } }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @api private
|
|
79
|
+
def record_file(interview, tail = "")
|
|
80
|
+
{ "interviews/#{file_stem(interview.fetch(:reference))}.md" => record(interview) + tail }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @api private
|
|
84
|
+
def file_stem(reference) = reference.to_s.gsub(/[^A-Za-z0-9._-]/, "_")
|
|
85
|
+
|
|
86
|
+
# @api private
|
|
87
|
+
def bluebook(interview)
|
|
88
|
+
name = interview.fetch(:subject)
|
|
89
|
+
things = accepted(interview, :things)
|
|
90
|
+
raise ArgumentError, "nothing was accepted: a draft needs at least one thing" if things.empty?
|
|
91
|
+
|
|
92
|
+
out = ["Hecks.bluebook #{name.inspect} do"]
|
|
93
|
+
out.concat(header(interview))
|
|
94
|
+
out << %( vision "TODO: say in one sentence what #{name} is for.")
|
|
95
|
+
out << " supporting" << ""
|
|
96
|
+
out.concat(rule_lines(interview))
|
|
97
|
+
out << fragment(interview).chomp << "end"
|
|
98
|
+
"#{out.join("\n")}\n"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The aggregates, with their commands: the part a later interview offers to merge.
|
|
102
|
+
# @api private
|
|
103
|
+
def fragment(interview)
|
|
104
|
+
things = accepted(interview, :things)
|
|
105
|
+
actions = accepted(interview, :actions)
|
|
106
|
+
placed = things.map { |t| word(t[:name]) }
|
|
107
|
+
blocks = things.map { |thing| aggregate(interview, thing, actions) }
|
|
108
|
+
loose = actions.reject { |a| placed.include?(word(a[:thing])) }
|
|
109
|
+
blocks << unplaced(interview, loose) unless loose.empty?
|
|
110
|
+
"#{blocks.join("\n\n")}\n"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @api private
|
|
114
|
+
def header(interview)
|
|
115
|
+
[" # Drafted from interview #{interview[:reference]} with #{interview[:expert]}. Each name below cites the",
|
|
116
|
+
" # exchange it came from; edit freely."]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @api private
|
|
120
|
+
def rule_lines(interview)
|
|
121
|
+
rules = accepted(interview, :rules)
|
|
122
|
+
return [] if rules.empty?
|
|
123
|
+
|
|
124
|
+
[" # RULES the expert gave, for you to turn into `given` clauses:"] +
|
|
125
|
+
rules.map { |r| " # - #{cite(interview, r)} #{one_line(r[:statement])}" } + [""]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @api private
|
|
129
|
+
def aggregate(interview, thing, actions)
|
|
130
|
+
name = word(thing[:name])
|
|
131
|
+
field = Naming.snake(word(thing[:identifier]))
|
|
132
|
+
field_type = identifier_type(name, field)
|
|
133
|
+
mine = actions.select { |a| word(a[:thing]) == name }
|
|
134
|
+
creating = mine.select { |a| a[:creates] }
|
|
135
|
+
rest = mine - creating
|
|
136
|
+
lines = [" # #{source_note(interview, thing)}".rstrip,
|
|
137
|
+
" aggregate #{name.inspect} do",
|
|
138
|
+
" description #{"TODO: describe what a #{name} is.".inspect}", "",
|
|
139
|
+
" identified_by :#{field}", "",
|
|
140
|
+
" attribute :#{field}, #{field_type}", "",
|
|
141
|
+
" value_object #{field_type.inspect} do",
|
|
142
|
+
" attribute :value, String, pattern: #{PATTERN}",
|
|
143
|
+
" end"]
|
|
144
|
+
creating = [stub_create(name)] if creating.empty?
|
|
145
|
+
creating.each { |action| lines.concat(creating_command(interview, action, field, field_type)) }
|
|
146
|
+
rest.each { |action| lines.concat(mutating_command(interview, action, name)) }
|
|
147
|
+
(lines << " end").join("\n")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The creating command nothing in the interview named: a stub, marked for the developer.
|
|
151
|
+
# @api private
|
|
152
|
+
def stub_create(name) = { name: "Create", event: "#{name}Created", stub: true }
|
|
153
|
+
|
|
154
|
+
# @api private
|
|
155
|
+
def creating_command(interview, action, field, field_type)
|
|
156
|
+
verb = word(action[:name])
|
|
157
|
+
note = action[:stub] ? STUB_NOTE : " # #{source_note(interview, action)}"
|
|
158
|
+
["", note, " command #{verb.inspect} do", " goal #{"TODO: say what #{verb} does".inspect}", "",
|
|
159
|
+
" attribute :#{field}, #{field_type}", "", " emits #{word(action[:event]).inspect}", " end"]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @api private
|
|
163
|
+
def mutating_command(interview, action, name)
|
|
164
|
+
verb = word(action[:name])
|
|
165
|
+
["", " # #{source_note(interview, action)}", " command #{verb.inspect} do",
|
|
166
|
+
" goal #{"TODO: say what #{verb} does".inspect}", "", " reference_to #{name}", "",
|
|
167
|
+
" emits #{word(action[:event]).inspect}", " end"]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# An action whose thing nobody accepted has nowhere to go; it is kept as a comment, not lost.
|
|
171
|
+
# @api private
|
|
172
|
+
def unplaced(interview, actions)
|
|
173
|
+
lines = [" # UNPLACED: these accepted actions name a thing that was not accepted. Place each, or drop it."]
|
|
174
|
+
actions.each do |action|
|
|
175
|
+
lines << " # - #{cite(interview, action)} #{word(action[:name])} on #{action[:thing]}, announcing #{action[:event]}"
|
|
176
|
+
end
|
|
177
|
+
lines.join("\n")
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @api private
|
|
181
|
+
def identifier_type(name, field)
|
|
182
|
+
type = Naming.pascal(field)
|
|
183
|
+
type == name ? "#{type}Id" : type
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# @api private
|
|
187
|
+
def exchange_lines(exchange, number)
|
|
188
|
+
lines = ["#{number}. **Q:** #{one_line(exchange[:question])}", " **A:** #{one_line(exchange[:answer])}"]
|
|
189
|
+
topic = exchange[:topic].to_s
|
|
190
|
+
lines << " _Topic: #{one_line(topic)}_" unless topic.empty?
|
|
191
|
+
lines
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# @api private
|
|
195
|
+
def finding_lines(interview)
|
|
196
|
+
lines = interview.fetch(:things).map do |f|
|
|
197
|
+
"- Thing #{f[:number]}, #{f[:status]}: **#{f[:name]}**, identified by `#{f[:identifier]}` (exchange #{f[:source]})"
|
|
198
|
+
end
|
|
199
|
+
lines += interview.fetch(:actions).map do |f|
|
|
200
|
+
creates = f[:creates] ? ", creates it" : ""
|
|
201
|
+
"- Action #{f[:number]}, #{f[:status]}: **#{f[:name]}** on #{f[:thing]}, " \
|
|
202
|
+
"announcing `#{f[:event]}`#{creates} (exchange #{f[:source]})"
|
|
203
|
+
end
|
|
204
|
+
lines + interview.fetch(:rules).map do |f|
|
|
205
|
+
"- Rule #{f[:number]}, #{f[:status]}: #{one_line(f[:statement])} (exchange #{f[:source]})"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @api private
|
|
210
|
+
def accepted(interview, kind) = interview.fetch(kind).select { |finding| finding[:status] == ACCEPTED }
|
|
211
|
+
|
|
212
|
+
# A free-text name as a PascalCase word safe to write into Ruby: `lend a book` is `LendABook`.
|
|
213
|
+
# @api private
|
|
214
|
+
def word(text) = text.to_s.split(/[^A-Za-z0-9]+/).reject(&:empty?).map { |part| part.sub(/\A./, &:upcase) }.join
|
|
215
|
+
|
|
216
|
+
# @api private
|
|
217
|
+
def one_line(text) = text.to_s.gsub(/\s+/, " ").strip
|
|
218
|
+
|
|
219
|
+
# @api private
|
|
220
|
+
def cite(interview, finding) = "#{interview[:reference]} ##{finding[:source]}:"
|
|
221
|
+
|
|
222
|
+
# Where a name came from: the interview, the exchange, and a short quotation of the answer.
|
|
223
|
+
# @api private
|
|
224
|
+
def source_note(interview, finding)
|
|
225
|
+
exchange = interview.fetch(:exchanges)[finding[:source].to_i - 1]
|
|
226
|
+
note = "from #{cite(interview, finding)}"
|
|
227
|
+
return note unless exchange
|
|
228
|
+
|
|
229
|
+
text = one_line(exchange[:answer])
|
|
230
|
+
text = "#{text[0, 77]}..." if text.length > 80
|
|
231
|
+
"#{note} #{text.inspect}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# @api private
|
|
235
|
+
def plain(value)
|
|
236
|
+
return nil if value.nil?
|
|
237
|
+
|
|
238
|
+
found = value.respond_to?(:to_h) && !value.is_a?(String) ? value.to_h : value
|
|
239
|
+
found.is_a?(Hash) && found.key?(:value) ? found[:value] : found
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# @api private
|
|
243
|
+
def slice(record, *keys) = keys.to_h { |key| [key, plain(record[key])] }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -88,7 +88,7 @@ module Hecks
|
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
# `root:` is the source's parent, so a member declaring
|
|
91
|
-
# `
|
|
91
|
+
# `attaches ... from: :vendor` can vendor from it.
|
|
92
92
|
def boot(source)
|
|
93
93
|
root = File.directory?(source) ? File.dirname(source) : nil
|
|
94
94
|
registry = Runtime::Registry.new(root: root)
|
|
@@ -17,7 +17,7 @@ module Hecks
|
|
|
17
17
|
# Build output and other trees under a root that hold no domain a caller means.
|
|
18
18
|
IGNORED = %r{\A(rust|deploy|tmp|coverage)/}
|
|
19
19
|
|
|
20
|
-
# What a chapter name, a domain path and a launcher
|
|
20
|
+
# What a chapter name, a domain path and a launcher command may be made of.
|
|
21
21
|
NAME = /\A[A-Za-z][A-Za-z0-9_ ]*\z/
|
|
22
22
|
PATH = %r{\A[\w./-]+\z}
|
|
23
23
|
VERB = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
@@ -40,9 +40,28 @@ module Hecks
|
|
|
40
40
|
status.zero? ? puts(text) : abort(text)
|
|
41
41
|
LAUNCHER_TAIL
|
|
42
42
|
|
|
43
|
+
# What a Memory command does when refused: say why, from the record's `refusal`.
|
|
44
|
+
MEMORY_REFUSAL = <<~'LAUNCHER_REFUSAL'.gsub(/^/, " ").freeze
|
|
45
|
+
if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!"))
|
|
46
|
+
why = begin
|
|
47
|
+
require "json"
|
|
48
|
+
JSON.parse(text).dig("state", "refusal", "value")
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
warn(why ? why.sub(/\A(\w+::)+\w+: /, "") : reason)
|
|
53
|
+
else
|
|
54
|
+
puts text
|
|
55
|
+
warn reason
|
|
56
|
+
end
|
|
57
|
+
LAUNCHER_REFUSAL
|
|
58
|
+
|
|
43
59
|
# The generator's name as an executable launcher's header states it, whoever ran it.
|
|
44
60
|
GENERATOR = "hecks project_cli".freeze
|
|
45
61
|
|
|
62
|
+
# The launcher's test for a Memory command; a trailing `!` is not part of the name.
|
|
63
|
+
RUNS_ON_MEMORY = 'MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!"))'.freeze
|
|
64
|
+
|
|
46
65
|
module_function
|
|
47
66
|
|
|
48
67
|
# Writes a launcher for each named domain, or for every domain under `root`.
|
|
@@ -103,7 +122,7 @@ module Hecks
|
|
|
103
122
|
label = executable || "#{path}/#{snake}"
|
|
104
123
|
file = File.join(root, label)
|
|
105
124
|
text = launcher(path, name, program, executable: executable, legacy: setting[:legacy],
|
|
106
|
-
opted: !setting.empty?)
|
|
125
|
+
memory_commands: setting[:memory_commands], opted: !setting.empty?)
|
|
107
126
|
return skip(path, "#{label} is a directory") if File.directory?(file)
|
|
108
127
|
|
|
109
128
|
return :drifted if check && !(File.file?(file) && File.read(file) == text)
|
|
@@ -149,11 +168,12 @@ module Hecks
|
|
|
149
168
|
# @param program [String] the generator's invocation, named in the header
|
|
150
169
|
# @param executable [String, nil] the file's path under the root when it is not beside the
|
|
151
170
|
# domain; its program name is then that file's basename, and its header names the generator
|
|
152
|
-
# @param legacy [Array<String>, nil]
|
|
171
|
+
# @param legacy [Array<String>, nil] commands an executable hands to `Hecks::CLI` first
|
|
172
|
+
# @param memory_commands [Array<String>, nil] commands that default to Memory
|
|
153
173
|
# @param opted [Boolean] whether the chapter's world declares a `launcher` setting
|
|
154
174
|
# @return [String] the Ruby source
|
|
155
|
-
# @raise [ArgumentError] if a name, path, executable or legacy
|
|
156
|
-
def launcher(path, name, program, executable: nil, legacy: nil, opted: !executable.nil?)
|
|
175
|
+
# @raise [ArgumentError] if a name, path, executable or legacy command is not plain
|
|
176
|
+
def launcher(path, name, program, executable: nil, legacy: nil, memory_commands: nil, opted: !executable.nil?)
|
|
157
177
|
plain!("chapter name", name, NAME)
|
|
158
178
|
plain!("domain path", path, PATH)
|
|
159
179
|
snake = Naming.snake(name)
|
|
@@ -174,45 +194,48 @@ module Hecks
|
|
|
174
194
|
where = shown = "#{path}/#{snake}"
|
|
175
195
|
end
|
|
176
196
|
|
|
177
|
-
handoff = legacy_handoff(Array(legacy)) if executable
|
|
197
|
+
handoff = legacy_handoff(Array(legacy)) + memory_default(Array(memory_commands)) if executable
|
|
178
198
|
encoding = opted ? ENCODING : ""
|
|
179
199
|
ending = opted ? OPTED_ENDING : PLAIN_ENDING
|
|
200
|
+
ending = quiet_ending(ending) if executable && !Array(memory_commands).empty?
|
|
180
201
|
|
|
181
202
|
<<~RUBY
|
|
182
203
|
#!/usr/bin/env ruby
|
|
183
204
|
|
|
184
205
|
# #{name}'s command line — GENERATED by #{program}. Do not edit.
|
|
185
206
|
#
|
|
186
|
-
# A launcher, not a CLI: the
|
|
207
|
+
# A launcher, not a CLI: the commands, their arguments and their refusals are
|
|
187
208
|
# projected from the bluebook beside it every time this runs, so a change
|
|
188
209
|
# to the chapter shows up here without re-minting anything.
|
|
189
210
|
#
|
|
190
|
-
# #{where} every
|
|
191
|
-
# #{where} <
|
|
211
|
+
# #{where} every command, and every question
|
|
212
|
+
# #{where} <command> --help what it wants, and how it refuses
|
|
192
213
|
|
|
193
214
|
#{encoding}$LOAD_PATH.unshift File.expand_path("#{up}lib", __dir__)
|
|
194
215
|
#{handoff}
|
|
195
216
|
require "hecks"
|
|
196
217
|
|
|
197
|
-
#{entry(name, boot, shown, opted)}
|
|
218
|
+
#{entry(name, boot, shown, opted, quiet: !Array(memory_commands).empty?)}
|
|
198
219
|
#{ending}
|
|
199
220
|
RUBY
|
|
200
221
|
end
|
|
201
222
|
|
|
202
223
|
# @api private
|
|
203
224
|
# @return [String] the launcher's middle: for an opted-in launcher, usage answered from the
|
|
204
|
-
# projection and a boot only for a line that runs a
|
|
225
|
+
# projection and a boot only for a line that runs a command; for any other, the boot and
|
|
205
226
|
# dispatch every launcher has always had, byte for byte
|
|
206
|
-
def entry(name, boot, shown, opted)
|
|
207
|
-
opted ? described_entry(name, boot, shown) : plain_entry(name, boot, shown)
|
|
227
|
+
def entry(name, boot, shown, opted, quiet: false)
|
|
228
|
+
opted ? described_entry(name, boot, shown, quiet: quiet) : plain_entry(name, boot, shown)
|
|
208
229
|
end
|
|
209
230
|
|
|
210
231
|
# @api private
|
|
211
232
|
# @return [String] the opted-in launcher's middle
|
|
212
|
-
def described_entry(name, boot, shown)
|
|
233
|
+
def described_entry(name, boot, shown, quiet: false)
|
|
234
|
+
started = "Hecks.boot_described(described, install_doors: false)"
|
|
235
|
+
started = "Hecks::Doors::LauncherOptions.quietly(hold: #{RUNS_ON_MEMORY}) { #{started} }" if quiet
|
|
213
236
|
<<~RUBY.chomp
|
|
214
237
|
# Usage is answered from the projected chapter alone: no adapter is bound and no
|
|
215
|
-
# database is opened. Only a line that runs a
|
|
238
|
+
# database is opened. Only a line that runs a command boots the domain,
|
|
216
239
|
# and it boots from what the usage check already loaded.
|
|
217
240
|
domain = #{boot}
|
|
218
241
|
program = "#{shown}"
|
|
@@ -227,7 +250,7 @@ module Hecks
|
|
|
227
250
|
)
|
|
228
251
|
unless text
|
|
229
252
|
runtime = begin
|
|
230
|
-
|
|
253
|
+
#{started}
|
|
231
254
|
rescue StandardError => e
|
|
232
255
|
abort "cannot open #{name}: \#{e.message.lines.first.strip}"
|
|
233
256
|
end
|
|
@@ -265,11 +288,39 @@ module Hecks
|
|
|
265
288
|
end
|
|
266
289
|
|
|
267
290
|
# @api private
|
|
268
|
-
# @
|
|
291
|
+
# @param ending [String] the launcher's closing lines
|
|
292
|
+
# @return [String] the same lines, except that a memory command prints no settled record, only
|
|
293
|
+
# the reason it was refused: a person is at it, so the record is noise
|
|
294
|
+
def quiet_ending(ending)
|
|
295
|
+
ending.sub(" puts text\n warn reason\n", MEMORY_REFUSAL)
|
|
296
|
+
.sub("status.zero? ? puts(text)",
|
|
297
|
+
"exit 0 if status.zero? && MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp(\"!\"))\nstatus.zero? ? puts(text)")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# @api private
|
|
301
|
+
# @return [String] the lines that default the listed commands to Memory, or nothing
|
|
302
|
+
def memory_default(commands)
|
|
303
|
+
return "" if commands.empty?
|
|
304
|
+
|
|
305
|
+
commands.each { |command| plain!("memory command", command, VERB) }
|
|
306
|
+
|
|
307
|
+
<<~RUBY
|
|
308
|
+
|
|
309
|
+
# These commands keep nothing worth a database, so they run on Memory unless told otherwise.
|
|
310
|
+
# A person is at them: they wait for their result, say why when they are refused, and print
|
|
311
|
+
# no record when they end well.
|
|
312
|
+
MEMORY_COMMANDS = %w[#{commands.join(' ')}].freeze
|
|
313
|
+
ENV["HECKS_ENVIRONMENT"] ||= "memory" if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!"))
|
|
314
|
+
ARGV << "--wait" if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!")) && !ARGV.include?("--wait")
|
|
315
|
+
RUBY
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# @api private
|
|
319
|
+
# @return [String] the lines that hand the legacy commands to `Hecks::CLI`, or nothing
|
|
269
320
|
def legacy_handoff(legacy)
|
|
270
321
|
return "" if legacy.empty?
|
|
271
322
|
|
|
272
|
-
legacy.each { |
|
|
323
|
+
legacy.each { |command| plain!("legacy command", command, VERB) }
|
|
273
324
|
|
|
274
325
|
<<~RUBY
|
|
275
326
|
|
data/lib/hecks/cli/reference.rb
CHANGED
|
@@ -5,7 +5,8 @@ require_relative "../projection_files"
|
|
|
5
5
|
|
|
6
6
|
module Hecks
|
|
7
7
|
module CLI
|
|
8
|
-
# The command behind `hecks project_reference`: regenerates
|
|
8
|
+
# The command behind `hecks language_run.project_reference`: regenerates
|
|
9
|
+
# `docs/implemented/reference/` from
|
|
9
10
|
# the language's Syntax chapter. Tables come from the declaration; prose is kept from the
|
|
10
11
|
# committed pages.
|
|
11
12
|
module Reference
|
|
@@ -6,18 +6,30 @@ require_relative "../fuzzing"
|
|
|
6
6
|
|
|
7
7
|
module Hecks
|
|
8
8
|
module CLI
|
|
9
|
-
# The command behind `hecks seed_semantics_corpus`: seeds (or
|
|
10
|
-
# `expect` block of
|
|
9
|
+
# The command behind `hecks test_suite_run.seed_semantics_corpus`: seeds (or re-seeds) the
|
|
10
|
+
# `expect` block of corpus fixtures from the Ruby runtime. Review the result
|
|
11
11
|
# against `docs/semantics/bluebook-semantics.md` before committing.
|
|
12
12
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
13
|
+
# Seeds `spec/corpus/semantics/` and the conformance corpus ({FULL_SCRIPTS} plus
|
|
14
|
+
# `spec/corpus/rust_conformance/`, whose `expect` also freezes more).
|
|
15
|
+
#
|
|
16
|
+
# hecks test_suite_run.seed_semantics_corpus # fill what lacks an expect
|
|
17
|
+
# SEED=refusal_kind_lifecycle hecks test_suite_run.seed_semantics_corpus # re-seed one
|
|
15
18
|
#
|
|
16
19
|
# The seeded expectation is the definition, not a recording: both runtimes are compared to it.
|
|
17
20
|
# `occurred_at` is stripped from events (C7.3/C9.1); refusals keep verb, error and kind (C8.2).
|
|
18
21
|
module SeedSemanticsCorpus
|
|
19
22
|
module_function
|
|
20
23
|
|
|
24
|
+
# Full corpus scripts held to the conformance bar, keyed by file, valued by domain.
|
|
25
|
+
FULL_SCRIPTS = {
|
|
26
|
+
"spec/corpus/banking.json" => "examples/banking",
|
|
27
|
+
"spec/corpus/chess.json" => "examples/chess"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# The `expect` keys a conformance fixture freezes beyond the semantics three.
|
|
31
|
+
CONFORMANCE_KEYS = %w[queries sagas dry_runs reactions].freeze
|
|
32
|
+
|
|
21
33
|
# Seeds the fixtures that need it and reports which.
|
|
22
34
|
#
|
|
23
35
|
# @param root [String] the checkout whose `spec/corpus/semantics/` is seeded
|
|
@@ -26,12 +38,13 @@ module Hecks
|
|
|
26
38
|
# @return [Integer] the exit status, always 0
|
|
27
39
|
def call(root:, env: ENV, out: $stdout)
|
|
28
40
|
only = env.fetch("SEED", nil)
|
|
29
|
-
seeded =
|
|
41
|
+
seeded = corpus_paths(root).filter_map do |path, domain, full|
|
|
30
42
|
fixture = JSON.parse(File.read(path))
|
|
31
43
|
name = File.basename(path, ".json")
|
|
32
44
|
next if only ? only != name : fixture.key?("expect")
|
|
33
45
|
|
|
34
|
-
fixture["
|
|
46
|
+
fixture["domain"] ||= domain if domain
|
|
47
|
+
fixture["expect"] = expectation_for(root, fixture, full: full)
|
|
35
48
|
File.write(path, "#{JSON.pretty_generate(fixture)}\n")
|
|
36
49
|
name
|
|
37
50
|
end
|
|
@@ -39,19 +52,35 @@ module Hecks
|
|
|
39
52
|
0
|
|
40
53
|
end
|
|
41
54
|
|
|
55
|
+
# @param root [String] the checkout
|
|
56
|
+
# @return [Array<Array>] `[path, domain_to_add_or_nil, full_expect?]` for every seedable file
|
|
57
|
+
def corpus_paths(root)
|
|
58
|
+
semantics = Dir.glob(File.join(root, "spec/corpus/semantics", "*.json")).map { |p| [p, nil, false] }
|
|
59
|
+
conformance = Dir.glob(File.join(root, "spec/corpus/rust_conformance", "*.json")).map { |p| [p, nil, true] }
|
|
60
|
+
scripts = FULL_SCRIPTS.map { |file, domain| [File.join(root, file), domain, true] }
|
|
61
|
+
semantics + conformance + scripts
|
|
62
|
+
end
|
|
63
|
+
|
|
42
64
|
# Replays the fixture's script against the Ruby runtime. Each refusal's `"kind"` is reduced
|
|
43
65
|
# to its class name; `occurred_at` is stripped from events.
|
|
44
66
|
#
|
|
45
67
|
# @param root [String] the checkout the fixture's domain path is relative to
|
|
46
68
|
# @param fixture [Hash] the parsed fixture: `domain` and `steps`
|
|
47
|
-
# @
|
|
48
|
-
|
|
69
|
+
# @param full [Boolean] also freeze queries, sagas, dry runs, reactions (conformance)
|
|
70
|
+
# @return [Hash{String => Array}] its `"expect"` block: refusals, instances, events, and
|
|
71
|
+
# with `full` the {CONFORMANCE_KEYS}
|
|
72
|
+
def expectation_for(root, fixture, full: false)
|
|
49
73
|
result = Hecks::Fuzzing::Replay.call(File.join(root, fixture["domain"]), fixture["steps"])
|
|
50
|
-
{
|
|
74
|
+
expect = {
|
|
51
75
|
"refusals" => JSON.parse(JSON.generate(result[:refusals])).each { |r| r["kind"] = r["kind"]&.split("::")&.last },
|
|
52
76
|
"instances" => JSON.parse(JSON.generate(result[:instances])),
|
|
53
77
|
"events" => JSON.parse(JSON.generate(result[:events])).each { |e| e.delete("occurred_at") }
|
|
54
78
|
}
|
|
79
|
+
return expect unless full
|
|
80
|
+
|
|
81
|
+
# instances_at is Replay's per-query snapshot for the property harness, not an outcome.
|
|
82
|
+
expect["queries"] = JSON.parse(JSON.generate(result[:queries].map { |q| q.except(:instances_at) }))
|
|
83
|
+
expect.merge!(CONFORMANCE_KEYS.drop(1).to_h { |k| [k, JSON.parse(JSON.generate(result[k.to_sym]))] })
|
|
55
84
|
end
|
|
56
85
|
|
|
57
86
|
# @param out [IO] where the report goes
|