hecks 3.0.5 → 3.1.1
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 +24 -12
- 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 +32 -3
- data/lib/hecks/cli/check_era.rb +1 -1
- 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/domain_writer.rb +39 -0
- data/lib/hecks/cli/interview_agent.rb +59 -0
- data/lib/hecks/cli/interview_draft.rb +246 -0
- data/lib/hecks/cli/interview_run.rb +87 -0
- data/lib/hecks/cli/interview_session.rb +299 -0
- data/lib/hecks/cli/model_check.rb +1 -1
- data/lib/hecks/cli/project_cli.rb +51 -29
- 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 +3 -3
- data/lib/hecks/cli/spec_example.rb +1 -1
- data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
- data/lib/hecks/corpus.rb +12 -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 +53 -33
- data/lib/hecks/doors/launcher_options.rb +5 -5
- data/lib/hecks/doors/mcp_door.rb +40 -18
- data/lib/hecks/doors/mcp_door_scope.rb +244 -24
- data/lib/hecks/doors/usage_cache.rb +123 -0
- 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 +5 -5
- data/lib/hecks/grammar/grammar.hecksagon +2 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
- data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
- data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
- 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/adapters/terminal.rb +15 -0
- data/lib/hecks/hecks/codebase.bluebook +4 -3
- data/lib/hecks/hecks/context_map.hecksagon +2 -2
- data/lib/hecks/hecks/custodian.bluebook +81 -3
- data/lib/hecks/hecks/hecks.bluebook +18 -0
- data/lib/hecks/hecks/hecks.hecksagon +16 -4
- data/lib/hecks/hecks/hecks.world +7 -6
- 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/projections/deploy/box/settings.rb +324 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +268 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +68 -0
- data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
- data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +181 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
- data/lib/hecks/projections/deploy/box.rb +553 -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 +153 -111
- 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/loader.rb +31 -8
- 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/tickets/bluebook/tickets.bluebook +331 -0
- data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
- data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
- 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/lib/hecks.rb +1 -0
- 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/codegen/src/json_codec.rs +3 -0
- data/rust/codegen/src/naming.rs +4 -0
- data/rust/codegen/src/types.rs +45 -0
- 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/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 +51 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module CLI
|
|
5
|
+
# Writes the files `hecks init` and `hecks interview` produce, never replacing one (ADR 0087).
|
|
6
|
+
#
|
|
7
|
+
# Every target is checked before the first is written, so a refusal leaves nothing half-written.
|
|
8
|
+
module DomainWriter
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# @param files [Hash{String => String}] each file's path under the target, and its text
|
|
12
|
+
# @param target [String] the absolute path of the domain directory
|
|
13
|
+
# @return [Array<String>] the paths written, relative to the target
|
|
14
|
+
# @raise [ArgumentError] when any file, or a bluebook the files would add, is already there
|
|
15
|
+
def write!(files, target)
|
|
16
|
+
taken = taken(files, target)
|
|
17
|
+
raise ArgumentError, "nothing written; already there: #{taken.join(', ')}" unless taken.empty?
|
|
18
|
+
|
|
19
|
+
files.each do |path, text|
|
|
20
|
+
full = File.join(target, path)
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
22
|
+
File.write(full, text)
|
|
23
|
+
end
|
|
24
|
+
files.keys
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param files [Hash{String => String}] the files about to be written
|
|
28
|
+
# @param target [String] the domain directory
|
|
29
|
+
# @return [Array<String>] the paths in the way, relative to the target
|
|
30
|
+
def taken(files, target)
|
|
31
|
+
taken = files.keys.select { |path| File.exist?(File.join(target, path)) }
|
|
32
|
+
if files.keys.any? { |path| path.match?(%r{\Abluebook/[^/]+\.bluebook\z}) }
|
|
33
|
+
taken |= Dir.glob(File.join(target, "bluebook", "*.bluebook")).map { |full| full.delete_prefix("#{target}/") }
|
|
34
|
+
end
|
|
35
|
+
taken
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require_relative "../runtime/registry"
|
|
2
|
+
require_relative "../ports/agent"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module CLI
|
|
6
|
+
# The interviewer an interview session talks to: the agent port, resolved against a registry
|
|
7
|
+
# that holds one adapter (ADR 0088). The session never sees the adapter, only validated
|
|
8
|
+
# questions and proposals, so a spec can stand a scripted adapter in for the real one.
|
|
9
|
+
class InterviewAgent
|
|
10
|
+
PORT = File.expand_path("../ports/agent.port", __dir__)
|
|
11
|
+
CLAUDE_CODE = File.expand_path("../adapters/driven/claude_code.adapter", __dir__)
|
|
12
|
+
|
|
13
|
+
# The agent a real interview uses: the developer's own `claude`, no key or model of hecks's.
|
|
14
|
+
#
|
|
15
|
+
# @return [InterviewAgent] one resolved against the Claude Code adapter
|
|
16
|
+
def self.claude
|
|
17
|
+
require_relative "../adapters/driven/claude_code"
|
|
18
|
+
new(registry_with(CLAUDE_CODE))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param adapter_files [Array<String>] adapter declarations to load beside the agent port
|
|
22
|
+
# @return [Runtime::Registry] a registry in which exactly those adapters implement the port
|
|
23
|
+
def self.registry_with(*adapter_files)
|
|
24
|
+
registry = Runtime::Registry.new
|
|
25
|
+
Hecks.with_registry(registry) do
|
|
26
|
+
Kernel.load(PORT)
|
|
27
|
+
adapter_files.each { |file| Kernel.load(file) }
|
|
28
|
+
end
|
|
29
|
+
registry
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] whether a `claude` executable is on the path, so a model can be tried
|
|
33
|
+
def self.claude_available?
|
|
34
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, "claude")) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param registry [Runtime::Registry] a registry with the agent port and one adapter for it
|
|
38
|
+
def initialize(registry)
|
|
39
|
+
@registry = registry
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @param state [Hash] the interview so far, as the session describes it
|
|
43
|
+
# @param asked [Array<String>] the questions already asked
|
|
44
|
+
# @return [Ports::Agent::Question, nil] the next question, or nil if the adapter offered none
|
|
45
|
+
# @raise [Ports::Agent::Unavailable, Ports::Agent::ValidationError] when it could not answer
|
|
46
|
+
def question(state:, asked:)
|
|
47
|
+
Ports::Agent.ask(@registry, state: state, asked: asked).first
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param prose [String] what the expert just said, as the developer typed it
|
|
51
|
+
# @param state [Hash] the interview so far
|
|
52
|
+
# @return [Array<Ports::Agent::Proposal>] the findings the sentence names; none for a question
|
|
53
|
+
# @raise [Ports::Agent::Unavailable, Ports::Agent::ValidationError] when it could not answer
|
|
54
|
+
def proposals(prose:, state:)
|
|
55
|
+
Ports::Agent.interpret(@registry, prose: prose, state: state)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
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
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require_relative "../../hecks"
|
|
2
|
+
require_relative "domain_stub"
|
|
3
|
+
require_relative "domain_writer"
|
|
4
|
+
require_relative "interview_agent"
|
|
5
|
+
require_relative "interview_session"
|
|
6
|
+
|
|
7
|
+
module Hecks
|
|
8
|
+
module CLI
|
|
9
|
+
# The command behind `hecks interview <Name>` (ADR 0088): boots the SME chapter on demand, holds
|
|
10
|
+
# the interview at the terminal, then writes the first domain, or the next interview's proposed
|
|
11
|
+
# additions, and says what to type next. Nothing is written unless the interview concludes.
|
|
12
|
+
module InterviewRun
|
|
13
|
+
SME = File.expand_path("../sme", __dir__)
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @param name [String] the domain being discovered, as its bluebook will spell it
|
|
18
|
+
# @param adapter [String, nil] a persistence adapter from `DomainStub::ADAPTERS`
|
|
19
|
+
# @param dir [String, nil] where the domain goes; the snake-cased name here when nil
|
|
20
|
+
# @param expert [String, nil] who is interviewed; asked for when nil
|
|
21
|
+
# @param use_ai [Boolean] false runs the plain prompts (`--no-ai`)
|
|
22
|
+
# @param input [#gets] where the developer types
|
|
23
|
+
# @param output [#puts, #print] where the session speaks
|
|
24
|
+
# @param agent [#question, #proposals, nil] an interviewer to use instead of the real one
|
|
25
|
+
# @param runtime [Runtime, nil] a booted SME chapter to use instead of booting one
|
|
26
|
+
# @return [String] the report that was printed, or "" when nothing was written
|
|
27
|
+
# @raise [ArgumentError] when the name or adapter is refused, or a file would be replaced
|
|
28
|
+
def call(name:, adapter: nil, dir: nil, expert: nil, use_ai: true, input: $stdin, output: $stdout, agent: nil, runtime: nil)
|
|
29
|
+
DomainStub.support_files(name: name, adapter: adapter)
|
|
30
|
+
target = File.expand_path(dir || DomainStub.directory(name), Dir.pwd)
|
|
31
|
+
expert = who(expert, input, output)
|
|
32
|
+
result = InterviewSession.new(runtime: runtime || Hecks.boot(SME), agent: interviewer(use_ai, agent, output),
|
|
33
|
+
input: input, output: output, reference: next_reference(target),
|
|
34
|
+
subject: name, expert: expert).call
|
|
35
|
+
return "" unless result.status == :concluded
|
|
36
|
+
|
|
37
|
+
report(target, DomainWriter.write!(files(result.interview, adapter, target), target))
|
|
38
|
+
.tap { |text| output.puts(text) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @api private
|
|
42
|
+
def who(expert, input, output)
|
|
43
|
+
return expert unless expert.to_s.strip.empty?
|
|
44
|
+
|
|
45
|
+
output.print("Who is the expert? ")
|
|
46
|
+
input.gets.to_s.strip.then { |text| text.empty? ? "the expert" : text }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @api private
|
|
50
|
+
def interviewer(use_ai, agent, output)
|
|
51
|
+
return nil unless use_ai
|
|
52
|
+
return agent if agent
|
|
53
|
+
return InterviewAgent.claude if InterviewAgent.claude_available?
|
|
54
|
+
|
|
55
|
+
output.puts("`claude` is not installed, so this runs without the AI. Use --no-ai to skip this note.")
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The first interview names a domain; a later one finds a bluebook there and offers additions.
|
|
60
|
+
# @api private
|
|
61
|
+
def files(interview, adapter, target)
|
|
62
|
+
return InterviewDraft.additions(interview) unless Dir.glob(File.join(target, "bluebook", "*.bluebook")).empty?
|
|
63
|
+
|
|
64
|
+
InterviewDraft.files(interview, adapter: adapter)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @api private
|
|
68
|
+
def next_reference(target)
|
|
69
|
+
"INT-#{Dir.glob(File.join(target, 'interviews', 'INT-*.md')).length + 1}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @api private
|
|
73
|
+
def report(target, written)
|
|
74
|
+
where = target.delete_prefix("#{Dir.pwd}/")
|
|
75
|
+
lines = ["wrote #{written.length} #{written.length == 1 ? 'file' : 'files'} in #{where}/:"] +
|
|
76
|
+
written.sort.map { |path| " #{path}" }
|
|
77
|
+
lines << "" << "next:"
|
|
78
|
+
if written.any? { |path| path.start_with?("bluebook/") }
|
|
79
|
+
lines << " hecks docs #{where}/bluebook" << " hecks console subject=#{where}"
|
|
80
|
+
else
|
|
81
|
+
lines << " merge the proposed additions in #{where}/#{written.first} into the domain's bluebook"
|
|
82
|
+
end
|
|
83
|
+
lines.join("\n")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|