hecks 3.1.3 → 3.2.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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  3. data/lib/hecks/cli/check_era.rb +3 -39
  4. data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
  5. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  6. data/lib/hecks/deploy/oidc.json +15 -0
  7. data/lib/hecks/doors/mcp_door.rb +16 -2
  8. data/lib/hecks/doors/mcp_door_scope.rb +43 -4
  9. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  10. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  11. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  12. data/lib/hecks/hecks/adapters/cost_explorer.adapter +7 -0
  13. data/lib/hecks/hecks/adapters/cost_explorer.rb +102 -0
  14. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  15. data/lib/hecks/hecks/adapters/site_toolchain.rb +21 -11
  16. data/lib/hecks/hecks/hecks.hecksagon +8 -0
  17. data/lib/hecks/language/bluebook/vocabulary.bluebook +7 -7
  18. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  19. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  20. data/lib/hecks/projections/deploy/box/settings.rb +23 -2
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -1
  22. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  23. data/lib/hecks/projections/deploy/box.rb +13 -2
  24. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  25. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  26. data/lib/hecks/projections/site/edge.rb +25 -11
  27. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  28. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  29. data/lib/hecks/projections/site/table/checks.rb +53 -7
  30. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  31. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  32. data/lib/hecks/projections/site/table.rb +16 -5
  33. data/lib/hecks/rust_build/kernel_input.rb +18 -6
  34. data/lib/hecks/rust_build/project_rust.rb +6 -132
  35. data/lib/hecks/site/bluebook/site.bluebook +27 -13
  36. data/lib/hecks/storehouse.rb +35 -1
  37. data/lib/hecks/tools/comment_style.rb +4 -2
  38. data/lib/hecks/tools/deploy_recipe.rb +1 -0
  39. data/lib/hecks/tools/rust_comment_style.rb +1 -1
  40. data/lib/hecks/tools/site_routes.rb +69 -28
  41. data/lib/hecks/version.rb +1 -1
  42. data/lib/hecks/vocabulary.rb +1 -1
  43. data/lib/hecks.rb +1 -0
  44. data/rust/build/src/lineage_pass.rs +1 -1
  45. data/rust/build/src/pipeline.rs +1 -1
  46. data/rust/codegen/src/attr.rs +1 -1
  47. data/rust/codegen/src/bridging.rs +1 -1
  48. data/rust/codegen/src/commands.rs +1 -1
  49. data/rust/codegen/src/constraints.rs +1 -1
  50. data/rust/codegen/src/dependency_planning.rs +1 -1
  51. data/rust/codegen/src/domain_generator.rs +2 -2
  52. data/rust/codegen/src/exemplar.rs +1 -1
  53. data/rust/codegen/src/expr_emitter.rs +1 -1
  54. data/rust/codegen/src/fielded.rs +1 -1
  55. data/rust/codegen/src/json_codec.rs +1 -1
  56. data/rust/codegen/src/main.rs +3 -4
  57. data/rust/codegen/src/mutations.rs +1 -1
  58. data/rust/codegen/src/naming.rs +1 -1
  59. data/rust/codegen/src/ports.rs +1 -1
  60. data/rust/codegen/src/queries.rs +3 -3
  61. data/rust/codegen/src/reactions.rs +1 -1
  62. data/rust/codegen/src/read_models.rs +2 -2
  63. data/rust/codegen/src/reference_specs.rs +1 -1
  64. data/rust/codegen/src/registry.rs +4 -4
  65. data/rust/codegen/src/shared.rs +1 -1
  66. data/rust/codegen/src/skip_reason.rs +1 -1
  67. data/rust/codegen/src/types.rs +4 -4
  68. data/rust/host/HECKS_RELEASE +1 -1
  69. data/rust/host/src/auth.rs +25 -1
  70. data/rust/host/src/needs.rs +94 -27
  71. data/rust/host/src/web.rs +9 -5
  72. data/rust/src/exemplar/commands.rs +1 -1
  73. data/rust/src/exemplar/constraints.rs +1 -1
  74. data/rust/src/exemplar/fielded.rs +1 -1
  75. data/rust/src/exemplar/json.rs +1 -1
  76. data/rust/src/exemplar/mod.rs +1 -1
  77. data/rust/src/exemplar/mutations.rs +1 -1
  78. data/rust/src/exemplar/queries.rs +1 -1
  79. data/rust/src/exemplar/reactions.rs +1 -1
  80. data/rust/src/exemplar/read_models.rs +1 -1
  81. data/rust/src/exemplar/registry.rs +1 -1
  82. data/rust/src/exemplar/types.rs +1 -1
  83. data/rust/src/kernel/cli.rs +2 -2
  84. data/rust/src/kernel/read_model.rs +1 -1
  85. data/rust/src/lib.rs +1 -1
  86. metadata +5 -23
  87. data/rust/project/bridging.rb +0 -257
  88. data/rust/project/commands.rb +0 -877
  89. data/rust/project/constraints.rb +0 -86
  90. data/rust/project/dependency_planning.rb +0 -178
  91. data/rust/project/domain_generator.rb +0 -983
  92. data/rust/project/exemplar.rb +0 -148
  93. data/rust/project/expr_emitter.rb +0 -72
  94. data/rust/project/fielded.rb +0 -141
  95. data/rust/project/json_codec.rb +0 -641
  96. data/rust/project/mutations.rb +0 -695
  97. data/rust/project/naming.rb +0 -223
  98. data/rust/project/ports.rb +0 -65
  99. data/rust/project/queries.rb +0 -493
  100. data/rust/project/reactions.rb +0 -317
  101. data/rust/project/read_models.rb +0 -690
  102. data/rust/project/reference_specs.rb +0 -27
  103. data/rust/project/registry.rb +0 -396
  104. data/rust/project/skip_reason.rb +0 -29
  105. data/rust/project/types.rb +0 -225
  106. data/rust/project/write_if_changed.rb +0 -7
  107. data/rust/project.rb +0 -30
  108. data/rust/project_rust_pipeline.rb +0 -391
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 88b11874249737158a9451d66aa0842ef88fb3f1ff047cced68d97b2ce9fdc5f
4
- data.tar.gz: 2872e381ce8e7fe5d46ef7a7623134dc23d923e57bdf194758effedaa1cc4671
3
+ metadata.gz: 9b963870a34a1483bc0156042a2967a72f8fc1b8e907ba5d8cac5a5382d4999d
4
+ data.tar.gz: 26697af02778cda282a44c1ca099e36a9866bf2d204ab5abb23df8ac2a86283a
5
5
  SHA512:
6
- metadata.gz: 76a39ab361adfb4e07bb558cb917d46bf3d7b6bb21340ff9789cb7b6c10be47a3197df4f394e8da73129feeb5b8439d9401f5e83b8620e0a512fce966c891377
7
- data.tar.gz: 58f1514450a234f3807897401daf7db410739463a8603bf58b7d73e548f14424211b4d1c5f3af1dc7421b769f39582e15c6f22188932af3de054961eed2cb641
6
+ metadata.gz: a81b0ffb4b0e04024518169e3c6dd51a3f55b5da39659a852c2a0c9528453fe0eb9facf07d7b18bd35d7924c5a08331b2f1ad82a7e5c4c43c61fae55490d46ed
7
+ data.tar.gz: f6780cb0c58069a5049081c7a319424787c9fea1fd94a5085153d7b99533c0e1974c23dfeb3515b66f3b2d8d3c7ca83a08dcd5f024df02da2b2fc9609c3ba6b7
@@ -57,9 +57,9 @@ module Hecks
57
57
  end
58
58
  end
59
59
 
60
- # The release that drops the deprecated spellings of `attaches`; kept in step with
61
- # `Hecks::Doors::REMOVAL`, which this file cannot require.
62
- REMOVAL = "3.2.0".freeze
60
+ # The release that drops the deprecated spellings of `attaches`; independent of
61
+ # `Hecks::Doors::REMOVAL`, which covers the older `Facade` aliases.
62
+ REMOVAL = "3.3.0".freeze
63
63
 
64
64
  attr_reader :binds, :subscriptions, :attachments
65
65
 
@@ -2,11 +2,9 @@ require_relative "../ports/persistence/plugins/era/expected_era"
2
2
 
3
3
  module Hecks
4
4
  module CLI
5
- # The command behind `hecks check_era` and Custodian's `Host.CheckEra`: asks a running host
6
- # which era it reports and compares it with an allow-list file. Read-only: one GET, no writes.
5
+ # The check behind Custodian's `Host.CheckEra`: asks a running host which era it reports and
6
+ # compares it with an allow-list file. Read-only: one GET, no writes.
7
7
  module CheckEra
8
- USAGE = "usage: hecks check_era <url> <expected-era-file> [--timeout=<seconds>]".freeze
9
-
10
8
  # What a check found.
11
9
  #
12
10
  # @!attribute [r] verdict
@@ -14,7 +12,7 @@ module Hecks
14
12
  # @!attribute [r] version
15
13
  # @return [String] the gem version the host reports, empty when it reports none
16
14
  # @!attribute [r] line
17
- # @return [String] the sentence `hecks check_era` prints for this finding
15
+ # @return [String] the sentence the check reports for this finding
18
16
  Finding = Struct.new(:verdict, :version, :line)
19
17
 
20
18
  module_function
@@ -36,35 +34,6 @@ module Hecks
36
34
  Finding.new(verdict, document.fetch("version"), sentence(verdict, file))
37
35
  end
38
36
 
39
- # Runs `hecks check_era`: prints the finding and answers the exit status.
40
- #
41
- # @param argv [Array<String>] the url, the allow-list file and an optional `--timeout=N`
42
- # @param out [IO] where a finding goes
43
- # @param err [IO] where a refusal goes
44
- # @return [Integer] 0 when the era is expected or unlisted, 1 on a mismatch, 2 on bad
45
- # usage or an unreadable file, 3 when the host cannot be reached or answers no era
46
- def run(argv, out: $stdout, err: $stderr)
47
- timeout = 10
48
- args = argv.reject do |arg|
49
- next false unless arg.start_with?("--timeout=")
50
-
51
- timeout = arg.delete_prefix("--timeout=").to_f
52
- true
53
- end
54
- url, file = args
55
- return usage(err) unless url && file && args.size == 2 && timeout.positive?
56
-
57
- finding = assess(url, file, timeout: timeout)
58
- (finding.verdict.ok? ? out : err).puts(finding.line)
59
- finding.verdict.ok? ? 0 : 1
60
- rescue Errno::ENOENT, Errno::EACCES => e
61
- err.puts("cannot read #{file}: #{e.message}")
62
- 2
63
- rescue Runtime::EraCheck::ExpectedEra::Unreachable, Runtime::EraCheck::ExpectedEra::BadResponse => e
64
- err.puts("era check failed: #{e.message}")
65
- 3
66
- end
67
-
68
37
  # @param verdict [Runtime::EraCheck::ExpectedEra::Verdict] the comparison
69
38
  # @param file [String] the allow-list file, named in the sentence
70
39
  # @return [String] the one line that says what the comparison found
@@ -78,11 +47,6 @@ module Hecks
78
47
  "host reports era #{verdict.era}, which is not in #{file} (expected: #{verdict.allowed.join(', ')})"
79
48
  end
80
49
  end
81
-
82
- def usage(err)
83
- err.puts(USAGE)
84
- 2
85
- end
86
50
  end
87
51
  end
88
52
  end
@@ -665,6 +665,111 @@ Hecks.bluebook "Deploy" do
665
665
  trigger MakefileCheck::Flag, with: { refusal: :refusal }
666
666
  end
667
667
 
668
+ aggregate "CostCheck" do
669
+ description "One reading of what hosting costs a month, against a budget."
670
+
671
+ attribute :run, RunKey
672
+ attribute :budget, Budget
673
+ attribute :since, Since
674
+ attribute :report, Report, optional: true
675
+ attribute :refusal, Report, optional: true
676
+ identified_by :run
677
+
678
+ value_object "RunKey" do
679
+ attribute :value, String, pattern: '[^ \t\n\r]'
680
+
681
+ invariant("a run is named") { !value.to_s.empty? }
682
+ end
683
+
684
+ # Whole dollars a month.
685
+ value_object "Budget" do
686
+ attribute :value, Integer
687
+
688
+ invariant("a budget is positive") { value.positive? }
689
+ end
690
+
691
+ # The first day to count, as `YYYY-MM-DD`: the first complete day after the last change to
692
+ # what is running, so a bill that still carries the old setup is not read as the new one.
693
+ value_object "Since" do
694
+ attribute :value, String, pattern: '[0-9]{4}-[0-9]{2}-[0-9]{2}'
695
+ end
696
+
697
+ value_object "Report" do
698
+ attribute :value, String
699
+ end
700
+
701
+ lifecycle :status, default: "requested" do
702
+ transition "Pass" => "within_budget", from: "requested"
703
+ transition "Flag" => "flagged", from: "requested"
704
+ end
705
+
706
+ # The reading is the billed daily total from the first complete day since `since` up to
707
+ # yesterday, scaled to a month; today is left out because its bill is still partial.
708
+ command "Check" do
709
+ goal "Read what hosting has billed since a day and compare its monthly rate to a budget"
710
+
711
+ attribute :run, RunKey
712
+ attribute :budget, Budget
713
+ attribute :since, Since
714
+
715
+ sets :run
716
+ sets :budget
717
+ sets :since
718
+
719
+ emits "CostCheckRequested"
720
+ end
721
+
722
+ command "Pass" do
723
+ goal "Record that the monthly rate is within the budget, and what it was"
724
+
725
+ reference_to CostCheck
726
+ attribute :report, Report
727
+
728
+ sets :report
729
+
730
+ emits "CostWithinBudget"
731
+ end
732
+
733
+ command "Flag" do
734
+ goal "Record that the monthly rate is over the budget, or that it could not be read, and what was said"
735
+
736
+ reference_to CostCheck
737
+ attribute :refusal, Report
738
+
739
+ sets :refusal
740
+
741
+ emits "CostFlagged"
742
+ end
743
+
744
+ query "Verdict" do
745
+ description "What one reading found: its status, and the report or the reason it was flagged."
746
+ attribute :run, RunKey
747
+ where(run: :run)
748
+ order_by :run
749
+ end
750
+
751
+ query "Flagged" do
752
+ description "Every reading that was over its budget or could not be taken."
753
+ where(status: "flagged")
754
+ order_by :run
755
+ end
756
+ end
757
+
758
+ policy "MeasureWhenChecked" do
759
+ on "CostCheck.CostCheckRequested"
760
+ trigger CostCheck::CostExplorer::Measure, with: { run: :run }
761
+ end
762
+
763
+ policy "RecordTheCostWithinBudget" do
764
+ on "CostCheck.CostAnswered"
765
+ trigger CostCheck::Pass, with: { report: :report }
766
+ end
767
+
768
+ policy "RecordTheFlaggedCost" do
769
+ on "CostCheck.CostRefused"
770
+ trigger CostCheck::Flag, with: { refusal: :refusal }
771
+ end
772
+
668
773
  aggregate "TemplateComparison" do
669
774
  description "One offline comparison of two CloudFormation templates, by logical id."
670
775
 
@@ -35,6 +35,14 @@ Hecks.hecksagon "Deploy" do
35
35
  end
36
36
  end
37
37
 
38
+ Deploy::CostCheck.port "CostExplorer" do
39
+ asks "Measure", to: CostCheck do
40
+ attribute :run, RunKey
41
+ answers "CostAnswered"
42
+ refuses "CostRefused"
43
+ end
44
+ end
45
+
38
46
  Deploy::TemplateComparison.port "DeployToolchain" do
39
47
  asks "Compare", to: TemplateComparison do
40
48
  attribute :run, RunKey
@@ -6,6 +6,21 @@
6
6
  "verb": "Deploy::BoxTarget.Declare",
7
7
  "role": null
8
8
  },
9
+ {
10
+ "scope": "deploy:cost_check.check",
11
+ "verb": "Deploy::CostCheck.Check",
12
+ "role": null
13
+ },
14
+ {
15
+ "scope": "deploy:cost_check.flag",
16
+ "verb": "Deploy::CostCheck.Flag",
17
+ "role": null
18
+ },
19
+ {
20
+ "scope": "deploy:cost_check.pass",
21
+ "verb": "Deploy::CostCheck.Pass",
22
+ "role": null
23
+ },
9
24
  {
10
25
  "scope": "deploy:fargate_target.declare",
11
26
  "verb": "Deploy::FargateTarget.Declare",
@@ -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
- send_response(id, { tools: served.map { |tool| scope.present(tool) } })
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"
@@ -201,18 +201,40 @@ module Hecks
201
201
  end
202
202
  end
203
203
 
204
- # The tool as `tools/list` shows it: on a commands door, `dispatch` names the commands it
205
- # serves, so a caller sees them as an enum instead of finding out by being refused.
204
+ # The one domain a restricted door serves, when it serves exactly one: a call may leave
205
+ # `domain:` out and get it. Nil for an unrestricted door and for one that names several.
206
+ #
207
+ # @return [String, nil] the domain path as `HECKS_DOOR_DOMAINS` named it, resolved
208
+ def default_domain
209
+ @allowed_domains.first if restricted? && @allowed_domains.size == 1
210
+ end
211
+
212
+ # The arguments with the default domain filled in when the caller left it out.
213
+ #
214
+ # @param args [Hash] a tool call's arguments
215
+ # @return [Hash] `args`, with `"domain"` set when the door has a default and none was given
216
+ def with_default_domain(args)
217
+ return args unless default_domain && args["domain"].to_s.strip.empty?
218
+
219
+ args.merge("domain" => default_domain)
220
+ end
221
+
222
+ # The tool as `tools/list` shows it. A door that serves one domain makes `domain:` optional.
223
+ # On a commands door, `dispatch` also names the commands it serves as an enum, so a caller
224
+ # sees them as typed choices instead of finding out by being refused, and says what each
225
+ # takes and which role it declares.
206
226
  #
207
227
  # @param tool [Hash] a tool definition from `McpDoor::TOOLS`
228
+ # @param guide [Array<String>, nil] one line per allowed command (`Storehouse.command_guide`)
208
229
  # @return [Hash] the definition, narrowed for this door
209
- def present(tool)
230
+ def present(tool, guide = nil)
231
+ tool = domain_optional(tool) if default_domain
210
232
  return tool unless commands_mode? && tool[:name] == "dispatch"
211
233
 
212
234
  properties = tool[:inputSchema][:properties]
213
235
  narrowed = properties.merge(command: with_enum(properties[:command]),
214
236
  steps: properties[:steps].merge(items: step_items(properties[:steps][:items])))
215
- tool.merge(description: "#{tool[:description]} This door dispatches only: #{allowed_commands.join(', ')}.",
237
+ tool.merge(description: dispatch_description(tool[:description], guide),
216
238
  inputSchema: tool[:inputSchema].merge(properties: narrowed))
217
239
  end
218
240
 
@@ -287,6 +309,23 @@ module Hecks
287
309
  File.join(File.realpath(current), *missing)
288
310
  end
289
311
 
312
+ def domain_optional(tool)
313
+ schema = tool[:inputSchema]
314
+ return tool unless schema[:properties].key?(:domain)
315
+
316
+ note = "Optional: this door serves one domain, #{default_domain}, and uses it when you leave this out."
317
+ properties = schema[:properties].merge(domain: schema[:properties][:domain].merge(description: note))
318
+ tool.merge(inputSchema: schema.merge(properties: properties, required: schema[:required] - ["domain"]))
319
+ end
320
+
321
+ def dispatch_description(base, guide)
322
+ intro = "#{base} This door dispatches only the commands below, each with the role it declares: pass " \
323
+ "that role as `role`. A command that takes `run` takes a key you choose, and the call answers " \
324
+ "the record as it stands once its reactions have run; an argument marked * is required."
325
+ entries = Array(guide).empty? ? allowed_commands : guide
326
+ ([intro] + entries.map { |entry| "- #{entry}" }).join("\n")
327
+ end
328
+
290
329
  def with_enum(property)
291
330
  property.merge(enum: allowed_commands)
292
331
  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
@@ -118,8 +118,9 @@ module Hecks
118
118
 
119
119
  owner = owner_for_verb(bluebooks, entry[:verb]) || aggregate
120
120
 
121
+ args = with_declared_defaults(command, entry[:args])
121
122
  command.mutations.select { |m| RECOMPUTABLE_MUTATION_OPS.include?(m.op) }.filter_map do |mutation|
122
- expected = recompute_mutation(mutation, entry[:before][mutation.target], entry[:args], entry[:before],
123
+ expected = recompute_mutation(mutation, entry[:before][mutation.target], args, entry[:before],
123
124
  aggregate, command, owner)
124
125
  next if expected == :unrecomputable
125
126
 
@@ -134,6 +135,21 @@ module Hecks
134
135
  offenders.empty? || offenders.join("; ")
135
136
  end
136
137
 
138
+ # The arguments the command actually ran with: an argument the caller left out takes the
139
+ # default its attribute declares, as dispatch fills it, so the recomputation starts from
140
+ # the same facts.
141
+ #
142
+ # @param command [Object] the command the step dispatched
143
+ # @param args [Hash] the arguments the step offered
144
+ # @return [Hash] `args`, with each omitted defaulted argument filled
145
+ def with_declared_defaults(command, args)
146
+ command.attributes.each_with_object(args.dup) do |attribute, held|
147
+ next if attribute.default.nil? || held.key?(attribute.name.to_sym) || held.key?(attribute.name.to_s)
148
+
149
+ held[attribute.name.to_sym] = attribute.default
150
+ end
151
+ end
152
+
137
153
  # Root aggregate for `verb`, derived from the verb alone because hand-built
138
154
  # entries may lack `:domain`/`:aggregate`. Value-object types resolve against
139
155
  # the root's namespace only.
@@ -38,7 +38,9 @@ module Hecks
38
38
  status = Dir.chdir(@tree.root) { runner.call(["--example", example, path], out, out) }
39
39
  raise ConsoleCapture::Failure, out.string.strip unless status.zero?
40
40
 
41
- out.string.strip
41
+ out.string.strip.tap do |report|
42
+ raise ConsoleCapture::Failure, "the test runner printed nothing for #{file}" if report.empty?
43
+ end
42
44
  end
43
45
 
44
46
  private
@@ -47,7 +49,13 @@ module Hecks
47
49
  return self.class.runner if self.class.runner
48
50
 
49
51
  require "rspec/core"
50
- RSpec::Core::Runner.method(:run)
52
+ # A process that runs one example after another, as a resident door does, has to reset
53
+ # RSpec between runs: its reporter keeps the first run's output stream, so a later run
54
+ # would write into that and answer an empty report.
55
+ lambda do |args, err, out|
56
+ RSpec.reset
57
+ RSpec::Core::Runner.run(args, err, out)
58
+ end
51
59
  end
52
60
  end
53
61
  end
@@ -0,0 +1,7 @@
1
+ # Required by name, not relative to this file: a fuzz or model check boots a copy of this directory,
2
+ # and the implementation must load once, from `lib/`, not from the copy.
3
+ require "hecks/hecks/adapters/cost_explorer"
4
+
5
+ Hecks.adapter "CostExplorer" do
6
+ port "CostExplorer"
7
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "json"
5
+ require "date"
6
+
7
+ module Hecks
8
+ module Adapters
9
+ # The `CostExplorer` port's adapter: reads what an AWS account has billed, with the `aws`
10
+ # command, and compares the monthly rate to a budget.
11
+ #
12
+ # It reads the daily unblended cost from the check's `since` day up to yesterday. Today is left
13
+ # out because its bill is still partial, and a day is counted whatever Cost Explorer marks it
14
+ # (a day stays "estimated" for a while after it ends). The rate is the mean daily cost scaled
15
+ # to an average month. A rate within the budget is answered with a one-line report; a rate over
16
+ # it, a span with no complete day, or a failed `aws` call is refused with a reason, which the
17
+ # runtime records on the check.
18
+ class CostExplorer
19
+ # Days in an average month, for scaling a daily mean to a monthly rate.
20
+ DAYS_IN_MONTH = 30.4375
21
+
22
+ # How many of the biggest services the report names.
23
+ TOP_SERVICES = 3
24
+
25
+ # @param aggregate [Object, nil] unused
26
+ # @param settings [Hash] `today:` pins the day the span ends before, for tests
27
+ # @param root [String, nil] unused
28
+ def initialize(aggregate: nil, settings: {}, root: nil)
29
+ @today = settings[:today]
30
+ end
31
+
32
+ # Reads the billed cost since a day and compares its monthly rate to the budget.
33
+ #
34
+ # @param held [Hash] the check's fields: `budget:` whole dollars a month and `since:` the
35
+ # first day to count (`YYYY-MM-DD`), each a value object or a plain value
36
+ # @return [Hash{Symbol => Hash}] `report:` the rate against the budget, the shape
37
+ # `CostCheck.Pass` takes
38
+ # @raise [RuntimeError] when no complete day has passed since `since`, `aws` fails, or the
39
+ # rate is over the budget
40
+ def measure(**held)
41
+ budget = Integer(plain(held[:budget]))
42
+ first = Date.iso8601(plain(held[:since]).to_s)
43
+ last = today
44
+ raise "no complete day since #{first}; today (#{last}) is still being billed" if first >= last
45
+
46
+ days = daily_costs(first, last)
47
+ line = report_line(first, last, days, budget)
48
+ raise "over budget: #{line}" if monthly_rate(days) > budget
49
+
50
+ { report: { value: line } }
51
+ end
52
+
53
+ private
54
+
55
+ # @return [Date] the day the span ends before: today, in UTC
56
+ def today = @today ? Date.iso8601(@today.to_s) : Time.now.utc.to_date
57
+
58
+ # @return [Hash{String => Hash{String => Float}}] day => service => dollars
59
+ def daily_costs(first, last)
60
+ out, err, status = Open3.capture3(
61
+ "aws", "ce", "get-cost-and-usage", "--time-period", "Start=#{first},End=#{last}",
62
+ "--granularity", "DAILY", "--metrics", "UnblendedCost",
63
+ "--group-by", "Type=DIMENSION,Key=SERVICE", "--output", "json"
64
+ )
65
+ raise "aws ce get-cost-and-usage failed: #{err.strip.empty? ? out.strip : err.strip}" unless status.success?
66
+
67
+ rows = JSON.parse(out).fetch("ResultsByTime")
68
+ raise "Cost Explorer returned no days for #{first}..#{last}" if rows.empty?
69
+
70
+ rows.to_h { |row| [row.dig("TimePeriod", "Start"), by_service(row)] }
71
+ end
72
+
73
+ def by_service(row)
74
+ row.fetch("Groups").to_h do |group|
75
+ [group.fetch("Keys").first, group.dig("Metrics", "UnblendedCost", "Amount").to_f]
76
+ end
77
+ end
78
+
79
+ # @return [Float] the mean daily total, scaled to an average month
80
+ def monthly_rate(days)
81
+ mean = days.values.sum { |services| services.values.sum } / days.size
82
+ mean * DAYS_IN_MONTH
83
+ end
84
+
85
+ def report_line(first, last, days, budget)
86
+ mean = days.values.sum { |services| services.values.sum } / days.size
87
+ biggest = totals_by_service(days).max_by(TOP_SERVICES) { |_name, dollars| dollars }
88
+ named = biggest.map { |name, dollars| "#{name} $#{format('%.2f', dollars / days.size)}/day" }.join(", ")
89
+ "#{first}..#{last - 1} (#{days.size} days): $#{format('%.2f', mean)}/day, " \
90
+ "$#{format('%.2f', monthly_rate(days))}/month against $#{budget}; biggest: #{named}"
91
+ end
92
+
93
+ def totals_by_service(days)
94
+ days.values.each_with_object(Hash.new(0.0)) do |services, totals|
95
+ services.each { |name, dollars| totals[name] += dollars }
96
+ end
97
+ end
98
+
99
+ def plain(argument) = argument.is_a?(Hash) ? argument[:value] : argument
100
+ end
101
+ end
102
+ end
@@ -22,7 +22,7 @@ module Hecks
22
22
  CHECKOUT_MARKER = "hecks.gemspec"
23
23
 
24
24
  # What a copy holds: the workspace manifest, the kernel and every crate a domain build uses.
25
- PACKAGED = %w[Cargo.toml Cargo.lock project.rb project_rust_pipeline.rb project src codegen parser
25
+ PACKAGED = %w[Cargo.toml Cargo.lock src codegen parser
26
26
  host build web lsp].freeze
27
27
 
28
28
  # Left out of a copy: build output anywhere, the corpus tests, and the corpus domains.
@@ -9,13 +9,16 @@ module Hecks
9
9
  # The `SiteToolchain` port's adapter: projects a site's route table into its `routes.ts`, or
10
10
  # checks the file on disk is current.
11
11
  #
12
- # The projection runs the `project_site` tool in a child of the checkout, with its printing and
13
- # exit status captured: a tool that ends non-zero is a refusal whose reason is what it printed.
14
- # Outside a hecks checkout the ask is refused with "needs a hecks checkout".
12
+ # The projection runs the `project_site` tool in this process, with its printing and exit status
13
+ # captured: a tool that ends non-zero is a refusal whose reason is what it printed. The tool
14
+ # ships in the gem and reads only the project it is given, so it needs no hecks checkout.
15
15
  class SiteToolchain
16
16
  # The `Hecks::Tools` tool the ask runs, by ask.
17
17
  SCRIPTS = { project: "project_site" }.freeze
18
18
 
19
+ # The tool's flag for each `SiteProjection` field that takes a value.
20
+ FLAGS = { "out" => :out, "template" => :template, "extension" => :extension }.freeze
21
+
19
22
  # Accepts the arguments every driven adapter is built with and keeps none of them.
20
23
  #
21
24
  # @param aggregate [Object, nil] unused
@@ -25,17 +28,17 @@ module Hecks
25
28
 
26
29
  # Writes a project's `routes.ts` from its declared route table, or with `check` only compares.
27
30
  #
28
- # @param held [Hash] the `SiteProjection` record: `domain`, and `out` and `check` when set
31
+ # @param held [Hash] the `SiteProjection` record: `domain`, and `out`, `template`, `extension`
32
+ # and `check` when set
29
33
  # @return [Hash{Symbol => Hash}] `output:` one line per file written or found current
30
- # @raise [ConsoleCapture::Failure] when the tree is not a checkout, the route table is
31
- # refused (an unknown cache class, a duplicate path), or under `check` a file is out of date
34
+ # @raise [ConsoleCapture::Failure] when the route table is refused (an unknown cache class,
35
+ # a duplicate path), a path or the extension is refused, or under `check` a file is out
36
+ # of date
32
37
  def project(**held)
33
- flags = []
34
- flags << "--out=#{plain(held[:out])}" unless plain(held[:out]).nil?
38
+ flags = FLAGS.filter_map { |flag, key| "--#{flag}=#{located(key, held[key])}" unless plain(held[key]).nil? }
35
39
  flags << "--check" if plain(held[:check]) == true
36
- tree = Codebase::Tree.new
37
- tree.require_checkout!
38
- result = Codebase::RubyChild.new(tree).capture(SCRIPTS.fetch(:project), *flags, plain(held[:domain]))
40
+ result = Codebase::RubyChild.new(Codebase::Tree.new).capture(SCRIPTS.fetch(:project), *flags,
41
+ located(:domain, held[:domain]))
39
42
  raise ConsoleCapture::Failure, message_of(result) unless result.ok?
40
43
 
41
44
  { output: { value: result.out } }
@@ -48,6 +51,13 @@ module Hecks
48
51
  text.empty? ? "the tool ended with status #{result.status.exitstatus}" : text
49
52
  end
50
53
 
54
+ # The tool runs from the tool's own root, so a path the caller gave relative to where they
55
+ # stand is made absolute first; the extension is not a path.
56
+ def located(key, argument)
57
+ value = plain(argument)
58
+ value.nil? || key == :extension ? value : File.expand_path(value)
59
+ end
60
+
51
61
  def plain(argument) = argument.is_a?(Hash) ? argument[:value] : argument
52
62
  end
53
63
  end