hecks 3.2.0 → 3.3.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/lib/hecks/bluebook/assembly/contracts.rb +2 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
- data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
- data/lib/hecks/bluebook/query.rb +8 -5
- data/lib/hecks/cli/check_era.rb +3 -39
- data/lib/hecks/cli/interview_draft.rb +86 -31
- data/lib/hecks/cli/interview_session.rb +77 -26
- data/lib/hecks/cli/interview_shape.rb +179 -0
- data/lib/hecks/doors/cli_runner.rb +14 -1
- data/lib/hecks/doors/launcher_options.rb +13 -3
- data/lib/hecks/doors/mcp_door.rb +16 -2
- data/lib/hecks/doors/mcp_door_scope.rb +45 -5
- data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
- data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
- data/lib/hecks/fuzzing/qa_settings.rb +15 -34
- data/lib/hecks/gate/stages.yml +113 -8
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
- data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
- data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
- data/lib/hecks/hecks/adapters/git.rb +59 -0
- data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
- data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
- data/lib/hecks/hecks/adapters/site_toolchain.rb +88 -11
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +217 -0
- data/lib/hecks/hecks/hecks.hecksagon +24 -0
- data/lib/hecks/hecks/hecks.world +8 -3
- data/lib/hecks/hecks/site.world +5 -2
- data/lib/hecks/language/bluebook/query.bluebook +25 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +220 -7
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
- data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
- data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
- data/lib/hecks/projections/deploy/box/settings.rb +40 -6
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
- data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
- data/lib/hecks/projections/deploy/box.rb +28 -4
- data/lib/hecks/projections/deploy/fargate.rb +3 -3
- data/lib/hecks/projections/deploy/lambda.rb +8 -8
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
- data/lib/hecks/projections/site/edge/checks.rb +31 -5
- data/lib/hecks/projections/site/edge.rb +25 -11
- data/lib/hecks/projections/site/live_edge.rb +162 -0
- data/lib/hecks/projections/site/routes_ts.rb +50 -27
- data/lib/hecks/projections/site/site_cdn.rb +7 -4
- data/lib/hecks/projections/site/table/checks.rb +53 -7
- data/lib/hecks/projections/site/table/link_builder.rb +64 -0
- data/lib/hecks/projections/site/table/row_builder.rb +21 -6
- data/lib/hecks/projections/site/table.rb +16 -5
- data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
- data/lib/hecks/quality_control/quality_control.bluebook +76 -124
- data/lib/hecks/runtime/interpreting.rb +3 -22
- data/lib/hecks/runtime/needs.rb +48 -0
- data/lib/hecks/runtime/query_interpreter.rb +3 -0
- data/lib/hecks/runtime/registry/verification.rb +3 -3
- data/lib/hecks/rust_build/host.rb +147 -0
- data/lib/hecks/rust_build/kernel_input.rb +51 -17
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
- data/lib/hecks/rust_build/project_rust.rb +7 -140
- data/lib/hecks/rust_build.rb +1 -0
- data/lib/hecks/site/bluebook/site.bluebook +80 -14
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
- data/lib/hecks/storehouse.rb +35 -1
- data/lib/hecks/tools/comment_style.rb +4 -2
- data/lib/hecks/tools/gate.rb +1 -1
- data/lib/hecks/tools/rust_comment_style.rb +2 -4
- data/lib/hecks/tools/site_routes.rb +69 -28
- data/lib/hecks/tools/tools_doc.rb +204 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +152 -1
- data/lib/hecks.rb +1 -0
- data/qa/settings.yml +1 -2
- data/rust/build/src/lineage_pass.rs +31 -16
- data/rust/build/src/main.rs +4 -2
- data/rust/build/src/pipeline.rs +9 -20
- data/rust/codegen/src/attr.rs +1 -1
- data/rust/codegen/src/bridging.rs +8 -8
- data/rust/codegen/src/commands.rs +1 -1
- data/rust/codegen/src/constraints.rs +1 -1
- data/rust/codegen/src/dependency_planning.rs +1 -1
- data/rust/codegen/src/domain_generator.rs +2 -2
- data/rust/codegen/src/exemplar.rs +1 -1
- data/rust/codegen/src/expr_emitter.rs +1 -1
- data/rust/codegen/src/fielded.rs +1 -1
- data/rust/codegen/src/json.rs +214 -23
- data/rust/codegen/src/json_codec.rs +1 -1
- data/rust/codegen/src/literal.rs +6 -3
- data/rust/codegen/src/main.rs +23 -13
- data/rust/codegen/src/mutations.rs +1 -1
- data/rust/codegen/src/naming.rs +5 -3
- data/rust/codegen/src/optional_pass.rs +226 -0
- data/rust/codegen/src/ports.rs +1 -1
- data/rust/codegen/src/queries.rs +3 -3
- data/rust/codegen/src/reactions.rs +1 -1
- data/rust/codegen/src/read_models.rs +2 -2
- data/rust/codegen/src/reference_specs.rs +1 -1
- data/rust/codegen/src/registry.rs +4 -4
- data/rust/codegen/src/shared.rs +1 -1
- data/rust/{build → codegen}/src/sidecars.rs +9 -5
- data/rust/codegen/src/skip_reason.rs +1 -1
- data/rust/codegen/src/types.rs +4 -4
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +5 -0
- data/rust/host/src/needs.rs +193 -29
- data/rust/host/src/resend.rs +4 -4
- data/rust/host/src/ui_schema.rs +1 -1
- data/rust/parser/src/emit.rs +12 -0
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +2 -44
- data/rust/parser/src/parse/mod.rs +1 -0
- data/rust/parser/src/parse/needs.rs +60 -0
- data/rust/parser/src/parse/query.rs +3 -0
- data/rust/src/exemplar/commands.rs +1 -1
- data/rust/src/exemplar/constraints.rs +1 -1
- data/rust/src/exemplar/fielded.rs +1 -1
- data/rust/src/exemplar/json.rs +1 -1
- data/rust/src/exemplar/mod.rs +1 -1
- data/rust/src/exemplar/mutations.rs +1 -1
- data/rust/src/exemplar/queries.rs +1 -1
- data/rust/src/exemplar/reactions.rs +1 -1
- data/rust/src/exemplar/read_models.rs +1 -1
- data/rust/src/exemplar/registry.rs +1 -1
- data/rust/src/exemplar/types.rs +1 -1
- data/rust/src/kernel/cli.rs +18 -8
- data/rust/src/kernel/needs.rs +104 -9
- data/rust/src/kernel/read_model.rs +1 -1
- data/rust/src/lib.rs +1 -1
- metadata +24 -30
- data/lib/hecks/rust_build/append_optionals.rb +0 -79
- data/lib/hecks/rust_build/rust_literal.rb +0 -33
- data/lib/hecks/three_zero/forms.yml +0 -127
- data/lib/hecks/three_zero.rb +0 -12
- data/rust/build/src/json.rs +0 -421
- data/rust/build/src/optional_pass.rs +0 -150
- data/rust/project/bridging.rb +0 -257
- data/rust/project/commands.rb +0 -877
- data/rust/project/constraints.rb +0 -86
- data/rust/project/dependency_planning.rb +0 -178
- data/rust/project/domain_generator.rb +0 -983
- data/rust/project/exemplar.rb +0 -148
- data/rust/project/expr_emitter.rb +0 -72
- data/rust/project/fielded.rb +0 -141
- data/rust/project/json_codec.rb +0 -641
- data/rust/project/mutations.rb +0 -695
- data/rust/project/naming.rb +0 -223
- data/rust/project/ports.rb +0 -65
- data/rust/project/queries.rb +0 -493
- data/rust/project/reactions.rb +0 -317
- data/rust/project/read_models.rb +0 -690
- data/rust/project/reference_specs.rb +0 -27
- data/rust/project/registry.rb +0 -396
- data/rust/project/skip_reason.rb +0 -29
- data/rust/project/types.rb +0 -225
- data/rust/project/write_if_changed.rb +0 -7
- data/rust/project.rb +0 -30
- data/rust/project_rust_pipeline.rb +0 -391
|
@@ -24,36 +24,59 @@ module Hecks
|
|
|
24
24
|
|
|
25
25
|
TASK = "You interview a subject matter expert, through the developer who sits beside them, about the " \
|
|
26
26
|
"business domain called %<subject>s, so the developer can model it. Ask one plain-language " \
|
|
27
|
-
"question at a time about what the business keeps track of, what
|
|
28
|
-
"
|
|
27
|
+
"question at a time about what the business keeps track of, what each thing has, what happens to " \
|
|
28
|
+
"it and who does it, how it moves from one state to the next, and what must never happen. Use the " \
|
|
29
|
+
"expert's own words. Do not assume what kind of business it is, or an " \
|
|
29
30
|
"industry, from the name of the domain: learn it from what the expert says. When you interpret " \
|
|
30
31
|
"an answer, propose findings only with the verbs listed under `verbs`, using the argument " \
|
|
31
32
|
"names given there, and nothing else.".freeze
|
|
32
33
|
|
|
33
34
|
VERBS = {
|
|
34
|
-
"SME::Interview.ProposeThing"
|
|
35
|
+
"SME::Interview.ProposeThing" => {
|
|
35
36
|
"meaning" => "a thing the business keeps track of, and the field that identifies one of it",
|
|
36
37
|
"arguments" => %w[name identifier]
|
|
37
38
|
},
|
|
38
|
-
"SME::Interview.ProposeAction"
|
|
39
|
-
"meaning" => "something that happens to a thing, and the event it announces, in the past tense"
|
|
40
|
-
|
|
39
|
+
"SME::Interview.ProposeAction" => {
|
|
40
|
+
"meaning" => "something that happens to a thing, and the event it announces, in the past tense; " \
|
|
41
|
+
"creates is true for the action that brings the thing into being; takes lists, comma " \
|
|
42
|
+
"separated, the fields it needs to be told; by says who does it",
|
|
43
|
+
"arguments" => %w[name thing event creates takes by]
|
|
41
44
|
},
|
|
42
|
-
"SME::Interview.
|
|
45
|
+
"SME::Interview.ProposeField" => {
|
|
46
|
+
"meaning" => "something a thing has; values lists, comma separated, what it may be when the " \
|
|
47
|
+
"expert gave a closed set, and is left out for free text",
|
|
48
|
+
"arguments" => %w[thing name values]
|
|
49
|
+
},
|
|
50
|
+
"SME::Interview.ProposeTransition" => {
|
|
51
|
+
"meaning" => "the state an action leaves a thing in, and the state it had to be in before; the " \
|
|
52
|
+
"action that creates the thing has no from",
|
|
53
|
+
"arguments" => %w[thing action to from]
|
|
54
|
+
},
|
|
55
|
+
"SME::Interview.ProposeRule" => {
|
|
43
56
|
"meaning" => "a rule the expert stated, in their own words",
|
|
44
57
|
"arguments" => %w[statement]
|
|
45
58
|
}
|
|
46
59
|
}.freeze
|
|
47
60
|
|
|
48
|
-
# What each finding verb needs,
|
|
61
|
+
# What each finding verb needs, the fields it may leave out, and the SME commands that
|
|
62
|
+
# carry it out and decide it.
|
|
49
63
|
KINDS = {
|
|
50
|
-
"SME::Interview.ProposeThing"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
"SME::Interview.ProposeThing" => { kind: "thing", fields: %w[name identifier], optional: [],
|
|
65
|
+
propose: :propose_thing!, entity: "ThingFinding",
|
|
66
|
+
accept: "AcceptThing", reject: "RejectThing" },
|
|
67
|
+
"SME::Interview.ProposeAction" => { kind: "action", fields: %w[name thing event creates takes by],
|
|
68
|
+
optional: %w[creates takes by], propose: :propose_action!,
|
|
69
|
+
entity: "ActionFinding", accept: "AcceptAction", reject: "RejectAction" },
|
|
70
|
+
"SME::Interview.ProposeRule" => { kind: "rule", fields: %w[statement], optional: [],
|
|
71
|
+
propose: :propose_rule!, entity: "RuleFinding",
|
|
72
|
+
accept: "AcceptRule", reject: "RejectRule" },
|
|
73
|
+
"SME::Interview.ProposeField" => { kind: "field", fields: %w[thing name values], optional: %w[values],
|
|
74
|
+
propose: :propose_field!, entity: "FieldFinding",
|
|
75
|
+
accept: "AcceptField", reject: "RejectField" },
|
|
76
|
+
"SME::Interview.ProposeTransition" => { kind: "transition", fields: %w[thing action to from],
|
|
77
|
+
optional: %w[from], propose: :propose_transition!,
|
|
78
|
+
entity: "TransitionFinding", accept: "AcceptTransition",
|
|
79
|
+
reject: "RejectTransition" }
|
|
57
80
|
}.freeze
|
|
58
81
|
|
|
59
82
|
TRUE_WORDS = %w[true yes y 1].freeze
|
|
@@ -153,7 +176,7 @@ module Hecks
|
|
|
153
176
|
return say("Ignored a proposal for #{proposal.verb}: not a finding I know.") unless config
|
|
154
177
|
|
|
155
178
|
fields = fields_of(proposal, config)
|
|
156
|
-
missing = config[:fields].reject { |f| f
|
|
179
|
+
missing = config[:fields].reject { |f| config[:optional].include?(f) || fields[f].to_s.strip != "" }
|
|
157
180
|
return say("Ignored a #{config[:kind]} proposal with no #{missing.join(', ')}.") unless missing.empty?
|
|
158
181
|
|
|
159
182
|
number = propose(config, fields) or return
|
|
@@ -166,7 +189,7 @@ module Hecks
|
|
|
166
189
|
rows = proposal.arguments.to_h { |row| [row[:name].to_s, row[:value]] }
|
|
167
190
|
fields = rows.slice(*config[:fields])
|
|
168
191
|
fields["creates"] = TRUE_WORDS.include?(fields["creates"].to_s.downcase) if fields.key?("creates")
|
|
169
|
-
fields
|
|
192
|
+
fields.reject { |key, value| key != "creates" && config[:optional].include?(key) && value.to_s.strip.empty? }
|
|
170
193
|
end
|
|
171
194
|
|
|
172
195
|
def propose(config, fields)
|
|
@@ -192,19 +215,27 @@ module Hecks
|
|
|
192
215
|
case config[:kind]
|
|
193
216
|
when "thing" then "thing: #{fields['name']}, identified by #{fields['identifier']}"
|
|
194
217
|
when "action" then "action: #{fields['name']} on #{fields['thing']}, announcing #{fields['event']}" \
|
|
195
|
-
"#{', creating it' if fields['creates']}"
|
|
218
|
+
"#{', creating it' if fields['creates']}#{action_extras(fields)}"
|
|
219
|
+
when "field" then "field: #{fields['name']} of #{fields['thing']}#{", one of #{fields['values']}" if fields['values']}"
|
|
220
|
+
when "transition" then "transition: #{fields['action']} leaves #{fields['thing']} #{fields['to']}" \
|
|
221
|
+
"#{", from #{fields['from']}" if fields['from']}"
|
|
196
222
|
else "rule: #{fields['statement']}"
|
|
197
223
|
end
|
|
198
224
|
end
|
|
199
225
|
|
|
226
|
+
# @api private
|
|
227
|
+
def action_extras(fields)
|
|
228
|
+
"#{", taking #{fields['takes']}" if fields['takes']}#{", by #{fields['by']}" if fields['by']}"
|
|
229
|
+
end
|
|
230
|
+
|
|
200
231
|
# The plain prompt: findings typed by the developer, accepted as they are entered.
|
|
201
232
|
def manual_findings
|
|
202
233
|
loop do
|
|
203
|
-
kind = ask("Add a finding from that answer? thing, action or
|
|
234
|
+
kind = ask("Add a finding from that answer? thing, action, rule, field or transition (enter to skip): ")
|
|
204
235
|
kind = kind.to_s.strip.downcase
|
|
205
236
|
break if kind.empty?
|
|
206
237
|
|
|
207
|
-
typed = typed_finding(kind) or next say(" I only know thing, action and
|
|
238
|
+
typed = typed_finding(kind) or next say(" I only know thing, action, rule, field and transition.")
|
|
208
239
|
config = KINDS["SME::Interview.Propose#{kind.capitalize}"]
|
|
209
240
|
number = propose(config, typed) or next
|
|
210
241
|
decide(config, number, true)
|
|
@@ -216,6 +247,11 @@ module Hecks
|
|
|
216
247
|
when "thing" then named("Name" => "name", "Identified by" => "identifier")
|
|
217
248
|
when "action" then typed_action
|
|
218
249
|
when "rule" then named("The rule, in the expert's words" => "statement")
|
|
250
|
+
when "field" then named({ "On which thing" => "thing", "Name" => "name",
|
|
251
|
+
"Values it may take, comma separated (enter for any)" => "values" }, %w[values])
|
|
252
|
+
when "transition" then named({ "On which thing" => "thing", "Caused by which action" => "action",
|
|
253
|
+
"Leaves it in the state" => "to",
|
|
254
|
+
"From the state (enter if it starts there)" => "from" }, %w[from])
|
|
219
255
|
end
|
|
220
256
|
end
|
|
221
257
|
|
|
@@ -224,9 +260,11 @@ module Hecks
|
|
|
224
260
|
fields.merge("creates" => TRUE_WORDS.include?(ask(" Does it create the thing? [y/N] ").to_s.strip.downcase))
|
|
225
261
|
end
|
|
226
262
|
|
|
227
|
-
def named(prompts)
|
|
263
|
+
def named(prompts, optional = [])
|
|
228
264
|
fields = prompts.to_h { |label, key| [key, ask(" #{label}: ").to_s.strip] }
|
|
229
|
-
fields.
|
|
265
|
+
return nil unless fields.all? { |key, value| optional.include?(key) || !value.empty? }
|
|
266
|
+
|
|
267
|
+
fields.reject { |key, value| optional.include?(key) && value.empty? }
|
|
230
268
|
end
|
|
231
269
|
|
|
232
270
|
def suggest_enough
|
|
@@ -271,9 +309,11 @@ module Hecks
|
|
|
271
309
|
end
|
|
272
310
|
|
|
273
311
|
def accepted_state(plain)
|
|
274
|
-
{ things:
|
|
275
|
-
actions:
|
|
276
|
-
|
|
312
|
+
{ things: InterviewDraft.accepted(plain, :things).map { |f| f.slice(:name, :identifier) },
|
|
313
|
+
actions: InterviewDraft.accepted(plain, :actions).map { |f| f.slice(:name, :thing, :event, :creates, :takes, :by) },
|
|
314
|
+
fields: InterviewDraft.accepted(plain, :fields).map { |f| f.slice(:thing, :name, :values) },
|
|
315
|
+
transitions: InterviewDraft.accepted(plain, :transitions).map { |f| f.slice(:thing, :action, :to, :from) },
|
|
316
|
+
rules: InterviewDraft.accepted(plain, :rules).map { |f| f.slice(:statement) } }
|
|
277
317
|
end
|
|
278
318
|
|
|
279
319
|
# What the interview has not yet pinned down, in words a model can act on.
|
|
@@ -283,7 +323,18 @@ module Hecks
|
|
|
283
323
|
gaps = things.reject { |t| actions.any? { |a| a[:thing] == t } }.map { |t| "nothing is yet said to happen to #{t}" }
|
|
284
324
|
gaps += things.reject { |t| actions.any? { |a| a[:thing] == t && a[:creates] } }.map { |t| "nothing yet creates #{t}" }
|
|
285
325
|
unplaced = actions.reject { |a| things.include?(a[:thing].to_s) }
|
|
286
|
-
gaps + unplaced.map { |a| "#{a[:name]} names #{a[:thing]}, not yet a thing" }
|
|
326
|
+
gaps + unplaced.map { |a| "#{a[:name]} names #{a[:thing]}, not yet a thing" } + shape_gaps(plain, things, actions)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# What a thing is not yet said to have, and how it moves from one state to the next.
|
|
330
|
+
def shape_gaps(plain, things, actions)
|
|
331
|
+
fields = InterviewDraft.accepted(plain, :fields)
|
|
332
|
+
steps = InterviewDraft.accepted(plain, :transitions)
|
|
333
|
+
bare = things.reject { |t| fields.any? { |f| f[:thing] == t } }
|
|
334
|
+
gaps = bare.map { |t| "nothing is yet said #{t} has, beyond its identifier" }
|
|
335
|
+
changing = things.select { |t| actions.count { |a| a[:thing] == t && !a[:creates] } > 1 }
|
|
336
|
+
unmoved = changing.reject { |t| steps.any? { |s| s[:thing] == t } }
|
|
337
|
+
gaps + unmoved.map { |t| "nothing yet says how #{t} moves from one state to the next" }
|
|
287
338
|
end
|
|
288
339
|
|
|
289
340
|
def reason(error) = error.message.sub(/\A[A-Z]\w* refused\s+[—-]\s+/, "").strip
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
require_relative "../naming"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module CLI
|
|
5
|
+
# What an interview's fields, command inputs and state changes become in a drafted aggregate
|
|
6
|
+
# (ADR 0088): its attributes and their value objects, the fields a command is told, a note of
|
|
7
|
+
# who does it, and the lifecycle.
|
|
8
|
+
#
|
|
9
|
+
# Pure, like `InterviewDraft`, which calls it: each method answers lines of bluebook text and
|
|
10
|
+
# writes none. A name is written as `InterviewDraft.word` writes it.
|
|
11
|
+
module InterviewShape
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The accepted fields of one thing, each with the type it will be written under. The
|
|
15
|
+
# identifier is already a field, and a lifecycle owns `status`, so neither is repeated.
|
|
16
|
+
#
|
|
17
|
+
# @param found [Array<Hash>] the accepted field findings that name the thing
|
|
18
|
+
# @param name [String] the thing's name, which no type may repeat
|
|
19
|
+
# @param identifier [String] the identifying field, snake-cased
|
|
20
|
+
# @param identifier_type [String] the type the identifier is written under
|
|
21
|
+
# @param lifecycle [Boolean] whether the thing has a lifecycle
|
|
22
|
+
# @return [Array<Hash>] each field's `name`, `type` and `values`
|
|
23
|
+
def fields(found, name, identifier, identifier_type, lifecycle:)
|
|
24
|
+
taken = [name, identifier_type]
|
|
25
|
+
found.filter_map { |finding| field_row(finding, taken, identifier, lifecycle) }.uniq { |row| row[:name] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The attribute lines of the fields: required when the creating action takes one, else
|
|
29
|
+
# optional.
|
|
30
|
+
#
|
|
31
|
+
# @param fields [Array<Hash>] the thing's fields, from `fields`
|
|
32
|
+
# @param required [Array<String>] the field names the creating actions take
|
|
33
|
+
# @return [Array<String>] one line per field
|
|
34
|
+
def attributes(fields, required)
|
|
35
|
+
fields.map do |field|
|
|
36
|
+
suffix = required.include?(field[:name]) ? "" : ", optional: true"
|
|
37
|
+
" attribute :#{field[:name]}, #{field[:type]}#{suffix}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The value object of each field, a blank line before each: a closed set when the expert
|
|
42
|
+
# listed two or more values, and free text otherwise.
|
|
43
|
+
#
|
|
44
|
+
# @param fields [Array<Hash>] the thing's fields, from `fields`
|
|
45
|
+
# @return [Array<String>] the lines
|
|
46
|
+
def value_objects(fields)
|
|
47
|
+
fields.flat_map do |field|
|
|
48
|
+
rule = field[:values].size > 1 ? ", one_of: #{field[:values].inspect}" : ""
|
|
49
|
+
["", " value_object #{field[:type].inspect} do", " attribute :value, String#{rule}", " end"]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The field names an action says it is told.
|
|
54
|
+
#
|
|
55
|
+
# @param action [Hash] an accepted action
|
|
56
|
+
# @return [Array<String>] snake-cased field names
|
|
57
|
+
def takes(action)
|
|
58
|
+
action[:takes].to_s.split(/\s*,\s*/).map { |text| Naming.snake(InterviewDraft.word(text)) }.reject(&:empty?)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# What an action is told that the thing has as a field, and what it names that the thing
|
|
62
|
+
# does not. The identifier is never counted: a command already carries it.
|
|
63
|
+
#
|
|
64
|
+
# @param action [Hash] an accepted action
|
|
65
|
+
# @param fields [Array<Hash>] the thing's fields, from `fields`
|
|
66
|
+
# @param identifier [String] the identifying field, snake-cased
|
|
67
|
+
# @return [Array(Array<Hash>, Array<String>)] the fields it is told, and the names that have
|
|
68
|
+
# no field
|
|
69
|
+
def inputs(action, fields, identifier)
|
|
70
|
+
known, unknown = (takes(action) - [identifier]).partition { |text| fields.any? { |field| field[:name] == text } }
|
|
71
|
+
[known.map { |text| fields.find { |field| field[:name] == text } }, unknown]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @param inputs [Array<Hash>] the fields a command is told
|
|
75
|
+
# @return [Array<String>] a blank line and an attribute line for each, or none
|
|
76
|
+
def input_lines(inputs)
|
|
77
|
+
inputs.empty? ? [] : ["", *inputs.map { |f| " attribute :#{f[:name]}, #{f[:type]}" }]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @param inputs [Array<Hash>] the fields a command is told
|
|
81
|
+
# @return [Array<String>] a blank line and a `sets` line for each, or none
|
|
82
|
+
def assignment_lines(inputs)
|
|
83
|
+
inputs.empty? ? [] : ["", *inputs.map { |f| " sets :#{f[:name]}" }]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param unknown [Array<String>] names an action is told that no accepted field has
|
|
87
|
+
# @return [Array<String>] a note for each
|
|
88
|
+
def unknown_lines(unknown)
|
|
89
|
+
unknown.map { |text| " # TODO: takes #{text}, but no accepted field of that name." }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Who may do an action, as the expert said it. It is a note, not a role: a role is only
|
|
93
|
+
# checked once the domain attaches Governance.
|
|
94
|
+
#
|
|
95
|
+
# @param action [Hash] an accepted action
|
|
96
|
+
# @return [Array<String>] the note, or none
|
|
97
|
+
def who_lines(action)
|
|
98
|
+
by = action[:by].to_s.strip
|
|
99
|
+
by.empty? ? [] : [" # Who: #{by}. Declare it as a role once Governance is attached."]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The lifecycle the accepted transitions describe. The state a creating action's step
|
|
103
|
+
# leads to is where a thing starts; a step for an action the thing does not have is kept
|
|
104
|
+
# as a comment.
|
|
105
|
+
#
|
|
106
|
+
# @param steps [Array<Hash>] the thing's accepted transitions
|
|
107
|
+
# @param creating [Array<Hash>] the accepted actions that create it
|
|
108
|
+
# @param rest [Array<Hash>] its other accepted actions
|
|
109
|
+
# @return [Array<String>] the lines, or none when no step and start state can be placed
|
|
110
|
+
def lifecycle(steps, creating, rest)
|
|
111
|
+
births = creating.map { |action| InterviewDraft.word(action[:name]) }
|
|
112
|
+
has = ->(names, step) { names.include?(InterviewDraft.word(step[:action])) }
|
|
113
|
+
starts, moves = steps.partition { |step| has.call(births, step) }
|
|
114
|
+
heres = rest.map { |action| InterviewDraft.word(action[:name]) }
|
|
115
|
+
placed, loose = moves.partition { |step| has.call(heres, step) }
|
|
116
|
+
placed = merged(placed)
|
|
117
|
+
start = beginning(starts, placed)
|
|
118
|
+
return [] if start.empty? || placed.empty?
|
|
119
|
+
|
|
120
|
+
["", " lifecycle :status, default: #{start.inspect} do", *placed.map { |step| transition_line(step) },
|
|
121
|
+
" end", *loose.map { |step| loose_line(step) }]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @api private
|
|
125
|
+
def field_row(finding, taken, identifier, lifecycle)
|
|
126
|
+
field = Naming.snake(InterviewDraft.word(finding[:name]))
|
|
127
|
+
return if field == identifier || (lifecycle && field == "status")
|
|
128
|
+
|
|
129
|
+
type = Naming.pascal(field)
|
|
130
|
+
type = "#{type}Value" while taken.include?(type)
|
|
131
|
+
taken << type
|
|
132
|
+
{ name: field, type: type, values: finding[:values].to_s.split(/\s*,\s*|\s+or\s+/).map(&:strip).reject(&:empty?).uniq }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# One step for each action and state it leads to: the states it starts from are joined, and a
|
|
136
|
+
# step with no state to start from means it may be taken from any.
|
|
137
|
+
# @api private
|
|
138
|
+
def merged(steps)
|
|
139
|
+
steps.group_by { |step| [InterviewDraft.word(step[:action]), state_word(step[:to])] }.values.map do |group|
|
|
140
|
+
froms = group.map { |step| step[:from].to_s.strip }
|
|
141
|
+
group.first.merge(from: froms.any?(&:empty?) ? "" : froms.join(","))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The state a thing starts in: where its creating action's step leads, else the first state a
|
|
146
|
+
# change is said to start from.
|
|
147
|
+
# @api private
|
|
148
|
+
def beginning(starts, placed)
|
|
149
|
+
from = placed.map { |step| step[:from].to_s.split(",").first }.find { |text| !text.to_s.strip.empty? }
|
|
150
|
+
state_word(starts.first&.fetch(:to) || from)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @api private
|
|
154
|
+
def transition_line(step)
|
|
155
|
+
" transition #{InterviewDraft.word(step[:action]).inspect} => #{state_word(step[:to]).inspect}" \
|
|
156
|
+
"#{from_clause(step)}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @api private
|
|
160
|
+
def loose_line(step)
|
|
161
|
+
" # UNPLACED transition: #{InterviewDraft.word(step[:action])} to #{state_word(step[:to])}; " \
|
|
162
|
+
"no accepted action has that name."
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# The states a step may start from: none, one, or several.
|
|
166
|
+
# @api private
|
|
167
|
+
def from_clause(step)
|
|
168
|
+
states = step[:from].to_s.split(/\s*,\s*/).map { |text| state_word(text) }.reject(&:empty?)
|
|
169
|
+
return "" if states.empty?
|
|
170
|
+
|
|
171
|
+
states.one? ? ", from: #{states.first.inspect}" : ", from: %w[#{states.join(' ')}]"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A state as a lowercase word: `Not Yet Triaged` is `not_yet_triaged`.
|
|
175
|
+
# @api private
|
|
176
|
+
def state_word(text) = text.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -188,6 +188,7 @@ module Hecks
|
|
|
188
188
|
# @param launcher [Hash, nil] the chapter's `launcher` world setting; nil when not opted in
|
|
189
189
|
def dispatch(runtime, spec, name, rest, program, asking, bluebook: nil, launcher: nil)
|
|
190
190
|
rest, wait = LauncherOptions.take_wait(spec, rest) if launcher
|
|
191
|
+
wait ||= LauncherOptions.settled?(launcher, spec)
|
|
191
192
|
args = CliDoor.arguments(spec, rest)
|
|
192
193
|
|
|
193
194
|
return answer_query(runtime, spec, args, wait) if spec[:kind] == :query
|
|
@@ -252,11 +253,23 @@ module Hecks
|
|
|
252
253
|
events: (events.empty? ? handle.events : events).map(&:name) }.merge(extra)
|
|
253
254
|
if LauncherOptions.failed?(aggregate, state, launcher)
|
|
254
255
|
field = aggregate.lifecycle.field
|
|
255
|
-
why += [
|
|
256
|
+
why += [failure_sentence(aggregate, state, field)]
|
|
256
257
|
end
|
|
257
258
|
finish(JSON.pretty_generate(answer), why)
|
|
258
259
|
end
|
|
259
260
|
|
|
261
|
+
# The sentence for a record that ended in a failure state: the state, then the record's own
|
|
262
|
+
# `refusal` when it keeps one, without the class name an adapter's failure carries.
|
|
263
|
+
def failure_sentence(aggregate, state, field)
|
|
264
|
+
sentence = "#{aggregate.hecks_name} ended in the failure state #{state[field.to_sym].to_s.inspect}"
|
|
265
|
+
refusal = state[:refusal]
|
|
266
|
+
refusal = refusal.value if refusal.respond_to?(:value)
|
|
267
|
+
refusal = refusal[:value] if refusal.is_a?(Hash)
|
|
268
|
+
return sentence if refusal.to_s.strip.empty?
|
|
269
|
+
|
|
270
|
+
"#{sentence}: #{refusal.to_s.strip.sub(/\A(\w+::)+\w+: /, '')}"
|
|
271
|
+
end
|
|
272
|
+
|
|
260
273
|
# The answer and its status: 0 when nothing failed, else 1 with the reasons joined.
|
|
261
274
|
def finish(text, reasons)
|
|
262
275
|
reasons.empty? ? [text, 0] : [text, 1, reasons.join("\n")]
|
|
@@ -10,11 +10,10 @@ module Hecks
|
|
|
10
10
|
# A `.world` that declares a `launcher` setting switches these on for that chapter; a
|
|
11
11
|
# domain without one keeps the launcher's plain forms exactly:
|
|
12
12
|
#
|
|
13
|
-
# launcher "Launcher", run_keys: true, failure_states: %w[flagged failed],
|
|
14
|
-
# names: { "mcp" => "serve_mcp" }
|
|
15
|
-
#
|
|
16
13
|
# - **run_keys** mints the `run` key of a creating command that was given none.
|
|
17
14
|
# - **failure_states** are the lifecycle states `--wait` reports as a failure (exit 1).
|
|
15
|
+
# - **settled** lists the commands (`aggregate.command`) that always behave as if `--wait` was
|
|
16
|
+
# given, so a script gets their outcome in its exit status.
|
|
18
17
|
# - **names** maps a launcher name to the command it stands for (see `CliProjector`).
|
|
19
18
|
# - **streams** lists the questions `--stream` may tail, one JSON line per new entry.
|
|
20
19
|
module LauncherOptions
|
|
@@ -87,6 +86,17 @@ module Hecks
|
|
|
87
86
|
[rest, wait]
|
|
88
87
|
end
|
|
89
88
|
|
|
89
|
+
# Whether a command always waits for its reactions, as if `--wait` had been given.
|
|
90
|
+
#
|
|
91
|
+
# @param launcher [Hash, nil] the chapter's `launcher` setting
|
|
92
|
+
# @param spec [Hash] the command's projected spec
|
|
93
|
+
# @return [Boolean] true for a command the setting lists under `settled`
|
|
94
|
+
def settled?(launcher, spec)
|
|
95
|
+
return false unless launcher && spec[:kind] == :command
|
|
96
|
+
|
|
97
|
+
Array(launcher[:settled]).map(&:to_s).include?(spec[:short].to_s)
|
|
98
|
+
end
|
|
99
|
+
|
|
90
100
|
# Whether a question may be tailed with `--stream`: the chapter's `launcher` setting lists it
|
|
91
101
|
# under `streams`, by the question's own name.
|
|
92
102
|
#
|
data/lib/hecks/doors/mcp_door.rb
CHANGED
|
@@ -343,7 +343,20 @@ module Hecks
|
|
|
343
343
|
def answer_tool(name, args)
|
|
344
344
|
return scope.refusal(name) unless scope.permits_tool?(name)
|
|
345
345
|
|
|
346
|
-
call_tool(name, args)
|
|
346
|
+
call_tool(name, scope.with_default_domain(args))
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# One line per allowed command: what it takes and the role it declares, read from the booted
|
|
350
|
+
# domain the door serves. Nil when the door serves no single domain or it will not boot, so
|
|
351
|
+
# `tools/list` still answers, with the commands as a bare list.
|
|
352
|
+
def command_guide
|
|
353
|
+
return unless scope.commands_mode? && scope.default_domain
|
|
354
|
+
|
|
355
|
+
McpGuideCache.remember(scope.default_domain, scope.allowed_commands) do
|
|
356
|
+
Storehouse.command_guide(boot(scope.default_domain), scope.allowed_commands)
|
|
357
|
+
end
|
|
358
|
+
rescue StandardError
|
|
359
|
+
nil
|
|
347
360
|
end
|
|
348
361
|
|
|
349
362
|
def tool_result(payload)
|
|
@@ -374,7 +387,8 @@ module Hecks
|
|
|
374
387
|
nil # a notification — no id, no response
|
|
375
388
|
when "tools/list"
|
|
376
389
|
served = TOOLS.select { |tool| scope.permits_tool?(tool[:name]) }
|
|
377
|
-
|
|
390
|
+
guide = command_guide
|
|
391
|
+
send_response(id, { tools: served.map { |tool| scope.present(tool, guide) } })
|
|
378
392
|
when "tools/call"
|
|
379
393
|
send_response(id, tool_result(answer_tool(params["name"], params["arguments"] || {})))
|
|
380
394
|
when "ping"
|
|
@@ -28,7 +28,8 @@ module Hecks
|
|
|
28
28
|
# switch to, or flips a command from a preview to a change of state.
|
|
29
29
|
DENIED_ARGUMENTS = %w[
|
|
30
30
|
adapter artifact confirm expected from gem_only header health_path host no_wait npm_local
|
|
31
|
-
npm_only output path payload payload_file port rust_binary scheme state_path stdio url
|
|
31
|
+
npm_only output path payload payload_file port rust_binary scheme stage_dir state_path stdio url
|
|
32
|
+
write
|
|
32
33
|
].freeze
|
|
33
34
|
|
|
34
35
|
# Argument names whose values are paths (a comma-separated list for some). A restricted door
|
|
@@ -201,18 +202,40 @@ module Hecks
|
|
|
201
202
|
end
|
|
202
203
|
end
|
|
203
204
|
|
|
204
|
-
# The
|
|
205
|
-
#
|
|
205
|
+
# The one domain a restricted door serves, when it serves exactly one: a call may leave
|
|
206
|
+
# `domain:` out and get it. Nil for an unrestricted door and for one that names several.
|
|
207
|
+
#
|
|
208
|
+
# @return [String, nil] the domain path as `HECKS_DOOR_DOMAINS` named it, resolved
|
|
209
|
+
def default_domain
|
|
210
|
+
@allowed_domains.first if restricted? && @allowed_domains.size == 1
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The arguments with the default domain filled in when the caller left it out.
|
|
214
|
+
#
|
|
215
|
+
# @param args [Hash] a tool call's arguments
|
|
216
|
+
# @return [Hash] `args`, with `"domain"` set when the door has a default and none was given
|
|
217
|
+
def with_default_domain(args)
|
|
218
|
+
return args unless default_domain && args["domain"].to_s.strip.empty?
|
|
219
|
+
|
|
220
|
+
args.merge("domain" => default_domain)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The tool as `tools/list` shows it. A door that serves one domain makes `domain:` optional.
|
|
224
|
+
# On a commands door, `dispatch` also names the commands it serves as an enum, so a caller
|
|
225
|
+
# sees them as typed choices instead of finding out by being refused, and says what each
|
|
226
|
+
# takes and which role it declares.
|
|
206
227
|
#
|
|
207
228
|
# @param tool [Hash] a tool definition from `McpDoor::TOOLS`
|
|
229
|
+
# @param guide [Array<String>, nil] one line per allowed command (`Storehouse.command_guide`)
|
|
208
230
|
# @return [Hash] the definition, narrowed for this door
|
|
209
|
-
def present(tool)
|
|
231
|
+
def present(tool, guide = nil)
|
|
232
|
+
tool = domain_optional(tool) if default_domain
|
|
210
233
|
return tool unless commands_mode? && tool[:name] == "dispatch"
|
|
211
234
|
|
|
212
235
|
properties = tool[:inputSchema][:properties]
|
|
213
236
|
narrowed = properties.merge(command: with_enum(properties[:command]),
|
|
214
237
|
steps: properties[:steps].merge(items: step_items(properties[:steps][:items])))
|
|
215
|
-
tool.merge(description:
|
|
238
|
+
tool.merge(description: dispatch_description(tool[:description], guide),
|
|
216
239
|
inputSchema: tool[:inputSchema].merge(properties: narrowed))
|
|
217
240
|
end
|
|
218
241
|
|
|
@@ -287,6 +310,23 @@ module Hecks
|
|
|
287
310
|
File.join(File.realpath(current), *missing)
|
|
288
311
|
end
|
|
289
312
|
|
|
313
|
+
def domain_optional(tool)
|
|
314
|
+
schema = tool[:inputSchema]
|
|
315
|
+
return tool unless schema[:properties].key?(:domain)
|
|
316
|
+
|
|
317
|
+
note = "Optional: this door serves one domain, #{default_domain}, and uses it when you leave this out."
|
|
318
|
+
properties = schema[:properties].merge(domain: schema[:properties][:domain].merge(description: note))
|
|
319
|
+
tool.merge(inputSchema: schema.merge(properties: properties, required: schema[:required] - ["domain"]))
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def dispatch_description(base, guide)
|
|
323
|
+
intro = "#{base} This door dispatches only the commands below, each with the role it declares: pass " \
|
|
324
|
+
"that role as `role`. A command that takes `run` takes a key you choose, and the call answers " \
|
|
325
|
+
"the record as it stands once its reactions have run; an argument marked * is required."
|
|
326
|
+
entries = Array(guide).empty? ? allowed_commands : guide
|
|
327
|
+
([intro] + entries.map { |entry| "- #{entry}" }).join("\n")
|
|
328
|
+
end
|
|
329
|
+
|
|
290
330
|
def with_enum(property)
|
|
291
331
|
property.merge(enum: allowed_commands)
|
|
292
332
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../cache_dir"
|
|
5
|
+
require_relative "../storehouse"
|
|
6
|
+
require_relative "../bluebook/meta_validator/verdict_cache"
|
|
7
|
+
|
|
8
|
+
module Hecks
|
|
9
|
+
module Doors
|
|
10
|
+
# Remembers the command guide a commands door builds for `tools/list`, so a door that starts
|
|
11
|
+
# again does not boot its domain to describe its commands. A client waits only a moment for a
|
|
12
|
+
# server's tools, and a boot at that point costs more than the wait.
|
|
13
|
+
#
|
|
14
|
+
# An entry is keyed by the domain directory's files, a digest of the hecks code, and the
|
|
15
|
+
# allowed commands, so a change to any of them builds a new guide. The file is JSON, read only
|
|
16
|
+
# from a directory and a file that only this user can write (the verdict cache's own check),
|
|
17
|
+
# and only when it is a list of strings: a guide is text an agent reads as instructions.
|
|
18
|
+
module McpGuideCache
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The guide for a domain and its allowed commands, from the cache or else from the block,
|
|
22
|
+
# which is then remembered. A cache that cannot be written is not an error: the next start
|
|
23
|
+
# builds the guide again.
|
|
24
|
+
#
|
|
25
|
+
# @param domain [String] the domain directory the guide describes
|
|
26
|
+
# @param commands [Array<String>] the allowed command names
|
|
27
|
+
# @yieldreturn [Array<String>, nil] the guide, built when none is remembered
|
|
28
|
+
# @return [Array<String>, nil] the guide
|
|
29
|
+
def remember(domain, commands)
|
|
30
|
+
file = File.join(dir, "#{key(domain, commands)}.json")
|
|
31
|
+
cached = read(file)
|
|
32
|
+
return cached if cached
|
|
33
|
+
|
|
34
|
+
guide = yield
|
|
35
|
+
write(file, guide) if guide.is_a?(Array) && guide.any?
|
|
36
|
+
guide
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [String] the directory entries live in
|
|
40
|
+
def dir = Hecks::CacheDir.path("mcp_door_guides")
|
|
41
|
+
|
|
42
|
+
# @param domain [String] the domain directory
|
|
43
|
+
# @param commands [Array<String>] the allowed command names
|
|
44
|
+
# @return [String] a hex SHA-256 over the domain's files, the hecks code and the commands
|
|
45
|
+
def key(domain, commands)
|
|
46
|
+
parts = [Storehouse.fingerprint(domain), Bluebook::MetaValidator::VerdictCache.code_digest,
|
|
47
|
+
commands.sort.join(",")]
|
|
48
|
+
Digest::SHA256.hexdigest(parts.join("\0"))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param file [String] an entry's path
|
|
52
|
+
# @return [Array<String>, nil] the guide, or nil when absent, untrusted or malformed
|
|
53
|
+
def read(file)
|
|
54
|
+
return unless File.file?(file) && Bluebook::MetaValidator::VerdictCache.trusted?(file)
|
|
55
|
+
|
|
56
|
+
guide = JSON.parse(File.read(file))
|
|
57
|
+
guide if guide.is_a?(Array) && guide.all?(String)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param file [String] an entry's path
|
|
63
|
+
# @param guide [Array<String>] the guide to remember
|
|
64
|
+
# @return [void]
|
|
65
|
+
def write(file, guide)
|
|
66
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
67
|
+
temp = "#{file}.#{Process.pid}.tmp"
|
|
68
|
+
File.write(temp, JSON.generate(guide), perm: 0o600)
|
|
69
|
+
File.rename(temp, file)
|
|
70
|
+
rescue StandardError
|
|
71
|
+
FileUtils.rm_f(temp) if temp
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/hecks/doors.rb
CHANGED