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.
Files changed (187) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +3 -3
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/check_era.rb +3 -39
  10. data/lib/hecks/cli/interview_draft.rb +86 -31
  11. data/lib/hecks/cli/interview_session.rb +77 -26
  12. data/lib/hecks/cli/interview_shape.rb +179 -0
  13. data/lib/hecks/doors/cli_runner.rb +14 -1
  14. data/lib/hecks/doors/launcher_options.rb +13 -3
  15. data/lib/hecks/doors/mcp_door.rb +16 -2
  16. data/lib/hecks/doors/mcp_door_scope.rb +45 -5
  17. data/lib/hecks/doors/mcp_guide_cache.rb +75 -0
  18. data/lib/hecks/doors.rb +1 -1
  19. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  20. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  21. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  22. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  23. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +17 -1
  24. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  25. data/lib/hecks/gate/stages.yml +113 -8
  26. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  27. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +10 -2
  28. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  29. data/lib/hecks/hecks/adapters/git.rb +59 -0
  30. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  31. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  32. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  33. data/lib/hecks/hecks/adapters/rust_workspace.rb +1 -1
  34. data/lib/hecks/hecks/adapters/site_toolchain.rb +88 -11
  35. data/lib/hecks/hecks/codebase.behaviors +6 -0
  36. data/lib/hecks/hecks/codebase.bluebook +21 -0
  37. data/lib/hecks/hecks/custodian.behaviors +6 -0
  38. data/lib/hecks/hecks/custodian.bluebook +217 -0
  39. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  40. data/lib/hecks/hecks/hecks.world +8 -3
  41. data/lib/hecks/hecks/site.world +5 -2
  42. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  43. data/lib/hecks/language/bluebook/vocabulary.bluebook +220 -7
  44. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  45. data/lib/hecks/language/oidc.json +5 -0
  46. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +3 -3
  47. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  48. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  49. data/lib/hecks/projections/deploy/box/settings.rb +40 -6
  50. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +2 -0
  51. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  52. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  53. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  54. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  55. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  56. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +17 -0
  57. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  58. data/lib/hecks/projections/deploy/box.rb +28 -4
  59. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  60. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  61. data/lib/hecks/projections/model/deviations.rb +1 -1
  62. data/lib/hecks/projections/site/edge/behaviours.rb +5 -3
  63. data/lib/hecks/projections/site/edge/checks.rb +31 -5
  64. data/lib/hecks/projections/site/edge.rb +25 -11
  65. data/lib/hecks/projections/site/live_edge.rb +162 -0
  66. data/lib/hecks/projections/site/routes_ts.rb +50 -27
  67. data/lib/hecks/projections/site/site_cdn.rb +7 -4
  68. data/lib/hecks/projections/site/table/checks.rb +53 -7
  69. data/lib/hecks/projections/site/table/link_builder.rb +64 -0
  70. data/lib/hecks/projections/site/table/row_builder.rb +21 -6
  71. data/lib/hecks/projections/site/table.rb +16 -5
  72. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  73. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  74. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  75. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  76. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  77. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  78. data/lib/hecks/runtime/interpreting.rb +3 -22
  79. data/lib/hecks/runtime/needs.rb +48 -0
  80. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  81. data/lib/hecks/runtime/registry/verification.rb +3 -3
  82. data/lib/hecks/rust_build/host.rb +147 -0
  83. data/lib/hecks/rust_build/kernel_input.rb +51 -17
  84. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  85. data/lib/hecks/rust_build/project_rust.rb +7 -140
  86. data/lib/hecks/rust_build.rb +1 -0
  87. data/lib/hecks/site/bluebook/site.bluebook +80 -14
  88. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  89. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  90. data/lib/hecks/storehouse.rb +35 -1
  91. data/lib/hecks/tools/comment_style.rb +4 -2
  92. data/lib/hecks/tools/gate.rb +1 -1
  93. data/lib/hecks/tools/rust_comment_style.rb +2 -4
  94. data/lib/hecks/tools/site_routes.rb +69 -28
  95. data/lib/hecks/tools/tools_doc.rb +204 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +152 -1
  99. data/lib/hecks.rb +1 -0
  100. data/qa/settings.yml +1 -2
  101. data/rust/build/src/lineage_pass.rs +31 -16
  102. data/rust/build/src/main.rs +4 -2
  103. data/rust/build/src/pipeline.rs +9 -20
  104. data/rust/codegen/src/attr.rs +1 -1
  105. data/rust/codegen/src/bridging.rs +8 -8
  106. data/rust/codegen/src/commands.rs +1 -1
  107. data/rust/codegen/src/constraints.rs +1 -1
  108. data/rust/codegen/src/dependency_planning.rs +1 -1
  109. data/rust/codegen/src/domain_generator.rs +2 -2
  110. data/rust/codegen/src/exemplar.rs +1 -1
  111. data/rust/codegen/src/expr_emitter.rs +1 -1
  112. data/rust/codegen/src/fielded.rs +1 -1
  113. data/rust/codegen/src/json.rs +214 -23
  114. data/rust/codegen/src/json_codec.rs +1 -1
  115. data/rust/codegen/src/literal.rs +6 -3
  116. data/rust/codegen/src/main.rs +23 -13
  117. data/rust/codegen/src/mutations.rs +1 -1
  118. data/rust/codegen/src/naming.rs +5 -3
  119. data/rust/codegen/src/optional_pass.rs +226 -0
  120. data/rust/codegen/src/ports.rs +1 -1
  121. data/rust/codegen/src/queries.rs +3 -3
  122. data/rust/codegen/src/reactions.rs +1 -1
  123. data/rust/codegen/src/read_models.rs +2 -2
  124. data/rust/codegen/src/reference_specs.rs +1 -1
  125. data/rust/codegen/src/registry.rs +4 -4
  126. data/rust/codegen/src/shared.rs +1 -1
  127. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  128. data/rust/codegen/src/skip_reason.rs +1 -1
  129. data/rust/codegen/src/types.rs +4 -4
  130. data/rust/host/HECKS_RELEASE +1 -1
  131. data/rust/host/src/api.rs +1 -1
  132. data/rust/host/src/dispatch.rs +5 -0
  133. data/rust/host/src/needs.rs +193 -29
  134. data/rust/host/src/resend.rs +4 -4
  135. data/rust/host/src/ui_schema.rs +1 -1
  136. data/rust/parser/src/emit.rs +12 -0
  137. data/rust/parser/src/ir.rs +2 -0
  138. data/rust/parser/src/keywords.rs +2 -0
  139. data/rust/parser/src/main.rs +1 -0
  140. data/rust/parser/src/parse/command.rs +2 -44
  141. data/rust/parser/src/parse/mod.rs +1 -0
  142. data/rust/parser/src/parse/needs.rs +60 -0
  143. data/rust/parser/src/parse/query.rs +3 -0
  144. data/rust/src/exemplar/commands.rs +1 -1
  145. data/rust/src/exemplar/constraints.rs +1 -1
  146. data/rust/src/exemplar/fielded.rs +1 -1
  147. data/rust/src/exemplar/json.rs +1 -1
  148. data/rust/src/exemplar/mod.rs +1 -1
  149. data/rust/src/exemplar/mutations.rs +1 -1
  150. data/rust/src/exemplar/queries.rs +1 -1
  151. data/rust/src/exemplar/reactions.rs +1 -1
  152. data/rust/src/exemplar/read_models.rs +1 -1
  153. data/rust/src/exemplar/registry.rs +1 -1
  154. data/rust/src/exemplar/types.rs +1 -1
  155. data/rust/src/kernel/cli.rs +18 -8
  156. data/rust/src/kernel/needs.rs +104 -9
  157. data/rust/src/kernel/read_model.rs +1 -1
  158. data/rust/src/lib.rs +1 -1
  159. metadata +24 -30
  160. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  161. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  162. data/lib/hecks/three_zero/forms.yml +0 -127
  163. data/lib/hecks/three_zero.rb +0 -12
  164. data/rust/build/src/json.rs +0 -421
  165. data/rust/build/src/optional_pass.rs +0 -150
  166. data/rust/project/bridging.rb +0 -257
  167. data/rust/project/commands.rb +0 -877
  168. data/rust/project/constraints.rb +0 -86
  169. data/rust/project/dependency_planning.rb +0 -178
  170. data/rust/project/domain_generator.rb +0 -983
  171. data/rust/project/exemplar.rb +0 -148
  172. data/rust/project/expr_emitter.rb +0 -72
  173. data/rust/project/fielded.rb +0 -141
  174. data/rust/project/json_codec.rb +0 -641
  175. data/rust/project/mutations.rb +0 -695
  176. data/rust/project/naming.rb +0 -223
  177. data/rust/project/ports.rb +0 -65
  178. data/rust/project/queries.rb +0 -493
  179. data/rust/project/reactions.rb +0 -317
  180. data/rust/project/read_models.rb +0 -690
  181. data/rust/project/reference_specs.rb +0 -27
  182. data/rust/project/registry.rb +0 -396
  183. data/rust/project/skip_reason.rb +0 -29
  184. data/rust/project/types.rb +0 -225
  185. data/rust/project/write_if_changed.rb +0 -7
  186. data/rust/project.rb +0 -30
  187. data/rust/project_rust_pipeline.rb +0 -391
@@ -1,7 +1,8 @@
1
1
  # Quality control: every check writes its expectation down before it looks at the answer.
2
2
  # A bug is logged with the failing test that proves it, or it is not logged.
3
3
 
4
- # Tunable dials for the QA loop: values in qa/settings.yml, reasoning here.
4
+ # Tunable dials for the QA loop: values in qa/settings.yml, what each is in the `QaDial` rows of the
5
+ # Vocabulary chapter.
5
6
  # `require`, not `require_relative`: isolated fuzz boots load a copy of this file from a tmpdir.
6
7
  require "hecks/fuzzing/qa_settings"
7
8
 
@@ -11,111 +12,15 @@ unless defined?(QualityControlDials)
11
12
  # boots. HECKS_QA_SETTINGS_PATH lets a subprocess test read a different settings file.
12
13
  settings = Hecks::Fuzzing::QaSettings.load(ENV.fetch("HECKS_QA_SETTINGS_PATH", Hecks::Fuzzing::QaSettings::DEFAULT_PATH))
13
14
 
14
- # Minimum gap in seconds between one tick's report and the next dispatch; 0 is immediately.
15
- # Read by the `/loop hecks_qa` caller, not by any script.
16
- CADENCE_SECONDS = settings.cadence_seconds
17
-
18
- # Most PRs (Patch plus Improvement) `hecks quality_control patch.open` opens per calendar day; 0 is uncapped.
19
- PR_CAP_PER_DAY = settings.pr_cap_per_day
20
-
21
- # Fuzz depth by `Target.clean_streak`, in three discrete tiers, read by
22
- # `SweepDepth.for_streak`. The last row (`upto: Float::INFINITY`) is the ceiling.
23
- WIDENING_TIERS = settings.widening_tiers
24
-
25
- # How many `hecks quality_control query sweep.run <target>` children `--all` keeps running at once.
26
- SWEEP_MAX_PARALLEL = settings.sweep_max_parallel
27
-
28
- # How long the `/loop hecks_qa` caller waits for a tick's report before assuming it stalled.
29
- LIVENESS_FALLBACK_SECONDS = settings.liveness_fallback_seconds
15
+ # Each dial of qa/settings.yml as a constant named for it: `PR_CAP_PER_DAY` is `pr_cap_per_day`.
16
+ # What each is and takes is declared once, as its `QaDial` row in the Vocabulary chapter.
17
+ Hecks::Vocabulary.rows("QaDial").each do |dial|
18
+ const_set(dial.fetch("name").upcase, settings.public_send(dial.fetch("name")))
19
+ end
30
20
 
31
21
  # The identity the loop runs as. `WaivedBy` repeats this literal (invariants read literals,
32
22
  # not constants); `spec/quality_control_spec.rb` pins the two equal.
33
23
  AUTOMATED_ENGINEER = "qa_sweep"
34
-
35
- # How `hecks quality_control patch.open` opens a PR: not a draft, queued for auto-merge, on a branch that starts
36
- # with BRANCH_PREFIX.
37
- DRAFT_ONLY = settings.draft_only
38
- AUTO_MERGE = settings.auto_merge
39
- BRANCH_PREFIX = settings.branch_prefix
40
-
41
- # Fraction (0.0..1.0) of generated command steps mutated adversarially; 0 turns the layer off.
42
- # Higher finds refusal-ordering splits sooner but reaches deep state less often.
43
- ADVERSARIAL_FRACTION = settings.adversarial_fraction
44
-
45
- # Coverage-guided generation across one sweep's seeds (`Hecks::Fuzzing::CoverageCampaign`).
46
- GUIDED_GENERATION = settings.guided_generation
47
-
48
- # Fraction (0.0..1.0) of seeds that start from a prefix of an earlier seed's new coverage.
49
- # Off by default: measured, it trades breadth for depth (banking reached 1.41x the deep-state
50
- # tuples, chess lost 0.12x of all tuples), while favoring rare verbs alone never lost.
51
- CORPUS_SPLICE_PROBABILITY = settings.corpus_splice_probability
52
-
53
- # How many of the least-hit verbs the next seed weights up; 0 turns the lever off.
54
- FAVOR_RARE_VERBS = settings.favor_rare_verbs
55
-
56
- # Whether `hecks quality_control query sweep.run` also runs the self-consistency pass (cold rehydration, replay
57
- # idempotency, value-object JSON round trip) on the same generated sequence.
58
- SELF_CONSISTENCY_CHECKS = settings.self_consistency_checks
59
-
60
- # Candidate replays `hecks quality_control query sweep.run` may spend shrinking one surprised check; 0 turns shrinking
61
- # off. `concurrency` is never shrunk: a race does not reproduce on demand.
62
- SHRINK_BUDGET = settings.shrink_budget
63
-
64
- # Seconds of staleness that one point of yield score is worth in `RotationPriority.pick`.
65
- # Zero would make yield invisible, so pick a number that lets it matter.
66
- YIELD_WEIGHT_SECONDS = settings.yield_weight_seconds
67
-
68
- # Percent of the prior yield score kept at each release, so recent findings dominate.
69
- # Applied by the caller: the mutation language cannot express "decay, then add" in `Release`.
70
- YIELD_DECAY_PERCENT = settings.yield_decay_percent
71
-
72
- # Once a waiting target is this stale, `RotationPriority.pick` takes it whatever its yield
73
- # score, so a quiet target cannot starve.
74
- ROTATION_STALE_FLOOR_SECONDS = settings.rotation_stale_floor_seconds
75
-
76
- # Ceiling on `--seeds` for `--persistence-parity`, which pays for real Postgres per dispatch.
77
- # `hecks quality_control query sweep.run` clamps down, never up, and prints a note.
78
- PERSISTENCE_PARITY_SEED_CAP = settings.persistence_parity_seed_cap
79
-
80
- # Ceiling on `--seeds` for the concurrency mode, which needs two disposable schemas per seed.
81
- CONCURRENCY_SEED_CAP = settings.concurrency_seed_cap
82
-
83
- # The two adapters each adapter-parity mode compares, by mode name; a new pairing is a new
84
- # entry. `adapter_parity_sqlite` needs no `database:`/`schema:` because sqlite has no
85
- # disposable database.
86
- ADAPTER_PARITY_PAIRS = settings.adapter_parity_pairs
87
-
88
- # Which comparison modes `hecks quality_control query sweep.run` may run: the enabled ones intersected with what
89
- # `TargetCapabilities.infer(path)` finds in the target. `--modes a,b,c` overrides for one run.
90
- #
91
- # `wasm_front` and `adapter_parity_postgres` are off because nothing runs them; the others are
92
- # built and off because their I/O cost across the rotation is unmeasured. `hecks quality_control query sweep.run`
93
- # refuses to start when a mode outside `TargetCapabilities::RUNNABLE_MODES` is enabled.
94
- MODES = settings.modes
95
-
96
- # How often a generated role-gated command step carries a drawn caller (`role:`/`actor_id:`);
97
- # 0 turns the draw off.
98
- ROLE_DRAW_PROBABILITY = settings.role_draw_probability
99
-
100
- # Fraction of generated command steps offered as a dry run instead of a real dispatch; 0 is
101
- # off. Kept low: a dry run creates no state for later steps to act on.
102
- DRY_RUN_FRACTION = settings.dry_run_fraction
103
-
104
- # How many generated domains one `hecks quality_control sweep.tick` checks after the rotation sweep; 0 skips the
105
- # step.
106
- GENERATED_DOMAINS_PER_TICK = settings.generated_domains_per_tick
107
-
108
- # Whether generated domains are also compared against a compiled Rust binary built in a scratch
109
- # copy.
110
- GENERATED_DOMAINS_RUST = settings.generated_domains_rust
111
-
112
- # Generated sequences run per generated domain.
113
- GENERATED_DOMAIN_SEEDS = settings.generated_domain_seeds
114
-
115
- # Construct families a skipped query verb may belong to. A skip inside the list is logged Held,
116
- # one outside it Surprised. Shrink-only: spec/fuzzing/rust_gap_manifest_spec.rb fails when a
117
- # family here is declared by no committed manifest.
118
- STRUCTURAL_REFUSAL_BOUNDARY = settings.structural_refusal_boundary
119
24
  end
120
25
  end
121
26
 
@@ -1726,8 +1631,7 @@ Hecks.bluebook "QualityControl" do
1726
1631
  attribute :branch, PatchBranch
1727
1632
  attribute :commit, CommitRef
1728
1633
  attribute :title, PatchTitle
1729
- # Makes `PR_CAP_PER_DAY` countable (`OpenedSince`). `default: 0` hydrates older rows at the
1730
- # epoch, which the cap never counts.
1634
+ # When the PR was opened, in epoch seconds. `default: 0` hydrates older rows at the epoch.
1731
1635
  attribute :opened_at, Instant
1732
1636
 
1733
1637
  value_object "Instant" do
@@ -1775,8 +1679,7 @@ Hecks.bluebook "QualityControl" do
1775
1679
  # the record is written by the process that made it true. `now` is that script's clock read.
1776
1680
  # The script asks `dry_run?` of this command before it opens anything, so the two `given`s are
1777
1681
  # the rules for opening a PR at all: the branch is one this practice recognises as its own, and
1778
- # the bug it fixes is fixed. The prefix repeats `BRANCH_PREFIX` as a literal, as `WaivedBy`
1779
- # repeats `AUTOMATED_ENGINEER`; `spec/quality_control_spec.rb` pins the two equal.
1682
+ # the bug it fixes is fixed. The prefix is declared here, once.
1780
1683
  command "Open" do
1781
1684
  role "QA engineer"
1782
1685
  goal "Record that a fix has been opened as a pull request, the moment its number, branch and commit are already known"
@@ -1828,21 +1731,78 @@ Hecks.bluebook "QualityControl" do
1828
1731
  order_by :number
1829
1732
  end
1830
1733
 
1831
- # `hecks quality_control patch.open` compares this and `Improvement.OpenedSince` counts, from local midnight,
1832
- # against `PR_CAP_PER_DAY`. `gte` on a bound argument, the `Account.AtOrAbove` shape.
1833
- query "OpenedSince" do
1834
- description "Every patch opened at or after one instant, in number order — what today's count against PR_CAP_PER_DAY is read from."
1835
- attribute :since, Instant
1836
- where(:"opened_at.value" => { gte: :since })
1837
- order_by :number
1838
- end
1839
-
1840
1734
  query "All" do
1841
1735
  description "Every patch ever opened — what a runner reads back to reconcile against GitHub's own list."
1842
1736
  order_by :number
1843
1737
  end
1844
1738
  end
1845
1739
 
1740
+ # How many pull requests the practice has opened on one UTC day, and the rule that the day's cap
1741
+ # is not exceeded. A rule across many records belongs to an aggregate that owns it (ADR 0081): the
1742
+ # cap is a `given` on one record, not a count over `Patch` and `Improvement`, and a record saves
1743
+ # atomically, so two runs cannot both take the last slot. Identified by the day, so a new day is
1744
+ # a new record. `hecks quality_control patch.open` asks `Today` for the day's record, opens it
1745
+ # with `Open` when there is none, and takes a slot with `Take` once a PR is open.
1746
+ aggregate "DailyQuota" do
1747
+ description "One UTC day's count of the pull requests the practice has opened, and the rule that the day's cap is not exceeded."
1748
+
1749
+ identified_by :today
1750
+
1751
+ # Whole days since the epoch, the fact the runtime calls `today`.
1752
+ attribute :today, Day
1753
+ attribute :used, SlotsUsed
1754
+
1755
+ value_object "Day" do
1756
+ attribute :value, Integer, default: 0
1757
+ invariant("a day is not before the epoch") { !value.negative? }
1758
+ end
1759
+
1760
+ value_object "SlotsUsed" do
1761
+ attribute :value, Integer, default: 0
1762
+ invariant("a count of pull requests is not negative") { !value.negative? }
1763
+ end
1764
+
1765
+ value_object "SlotCap" do
1766
+ attribute :value, Integer
1767
+ invariant("a cap is not negative") { !value.negative? }
1768
+ end
1769
+
1770
+ command "Open" do
1771
+ role "QA engineer"
1772
+ goal "Open the day the clock reads, with no slot taken yet"
1773
+
1774
+ attribute :today, Day
1775
+ needs :today
1776
+
1777
+ sets :today
1778
+
1779
+ emits "QuotaOpened"
1780
+ end
1781
+
1782
+ # `cap` is the dial's value, `PR_CAP_PER_DAY`; 0 is uncapped. The script asks this as a dry
1783
+ # run before it opens a PR, so a spent day refuses before anything is opened.
1784
+ command "Take" do
1785
+ role "QA engineer"
1786
+ goal "Take one of the day's slots for a pull request, refused once the day's cap is spent"
1787
+
1788
+ reference_to DailyQuota
1789
+ attribute :cap, SlotCap
1790
+
1791
+ given("the day's cap is not spent") { cap.value == 0 || used.value < cap.value }
1792
+
1793
+ sets :used, increment: { value: 1 }
1794
+
1795
+ emits "SlotTaken"
1796
+ end
1797
+
1798
+ query "Today" do
1799
+ description "The quota of the day the clock reads: nothing before the day's first pull request."
1800
+ attribute :today, Day
1801
+ needs :today
1802
+ where(:"today.value" => :today)
1803
+ end
1804
+ end
1805
+
1846
1806
  # A PR for deliberate, non-bugfix work: a sibling of `Patch`, because `Bug` means "proven wrong"
1847
1807
  # and `Patch.Open` keeps its required `reference_to Bug`.
1848
1808
  # Born `opened`, with no investigating state: the work was decided before any code moved.
@@ -1864,7 +1824,7 @@ Hecks.bluebook "QualityControl" do
1864
1824
  attribute :branch, ImprovementBranch
1865
1825
  attribute :commit, CommitRef
1866
1826
  attribute :title, ImprovementTitle
1867
- # See `Patch.opened_at`: the same field, for the same cap.
1827
+ # See `Patch.opened_at`.
1868
1828
  attribute :opened_at, Instant
1869
1829
 
1870
1830
  value_object "Instant" do
@@ -1997,14 +1957,6 @@ Hecks.bluebook "QualityControl" do
1997
1957
  order_by :number
1998
1958
  end
1999
1959
 
2000
- # `Patch.OpenedSince`'s twin: the cap counts both kinds of PR.
2001
- query "OpenedSince" do
2002
- description "Every improvement opened at or after one instant, in number order — the other half of today's count against PR_CAP_PER_DAY."
2003
- attribute :since, Instant
2004
- where(:"opened_at.value" => { gte: :since })
2005
- order_by :number
2006
- end
2007
-
2008
1960
  query "All" do
2009
1961
  description "Every improvement ever landed — what a runner reads back to reconcile against GitHub's own list."
2010
1962
  order_by :number
@@ -1,6 +1,6 @@
1
1
  require_relative "value"
2
2
  require_relative "aggregate_lock"
3
- require_relative "../ports/clock"
3
+ require_relative "needs"
4
4
 
5
5
  module Hecks
6
6
  module Runtime
@@ -14,11 +14,6 @@ module Hecks
14
14
  interpreter.singleton_class.attr_accessor :trace
15
15
  end
16
16
 
17
- # What answers each outside fact a command may `needs` (ADR 0081). A fact is answered once,
18
- # before any given runs, and the answer rides in the command's own arguments, so the event
19
- # records it and a replay re-dispatches the recorded value rather than asking again.
20
- NEED_ANSWERS = { now: ->(registry) { Ports::Clock.now(registry) } }.freeze
21
-
22
17
  private
23
18
 
24
19
  # Fills each outside fact the command `needs` and the caller left out. A value the caller
@@ -32,13 +27,8 @@ module Hecks
32
27
  fill_defaults(command, fill_needs(command, args))
33
28
  end
34
29
 
35
- # Answers each fact the command `needs` that the caller left out.
36
- def fill_needs(command, args)
37
- missing = command.needs.reject { |fact| args.key?(fact) || args.key?(fact.to_s) }
38
- return args if missing.empty?
39
-
40
- args.merge(missing.to_h { |fact| [fact, need_value(command, fact)] })
41
- end
30
+ # Answers each fact the command `needs` that the caller left out (see `Needs`).
31
+ def fill_needs(command, args) = Needs.fill(command, args, registry: @registry)
42
32
 
43
33
  # Gives each argument the caller left out the default its attribute declares
44
34
  # (`attribute :runs, Count, default: 30`), so a declared default holds on every way in and
@@ -56,15 +46,6 @@ module Hecks
56
46
  args.merge(absent.to_h { |attribute| [attribute.name.to_sym, attribute.default] })
57
47
  end
58
48
 
59
- # The answer to one fact, in the shape the command's argument of that name takes: a bare
60
- # Integer for an Integer attribute, else the one-field value object the language declares
61
- # for an instant.
62
- def need_value(command, fact)
63
- answer = NEED_ANSWERS.fetch(fact).call(@registry)
64
- attribute = command.attributes.find { |held| held.name.to_s == fact.to_s }
65
- attribute&.type.to_s == "Integer" ? answer : { value: answer }
66
- end
67
-
68
49
  # Logged after the step's work, so trace order is completion order.
69
50
  def step(name)
70
51
  result = yield
@@ -0,0 +1,48 @@
1
+ require_relative "../ports/clock"
2
+
3
+ module Hecks
4
+ module Runtime
5
+ # Answers the outside facts a command or a query `needs` (ADR 0081). A fact is answered once,
6
+ # before any given or filter reads the arguments, and the answer rides in the declaration's own
7
+ # arguments, so an event records it and a replay re-dispatches the recorded value rather than
8
+ # asking again.
9
+ module Needs
10
+ # What answers each fact a declaration may need: `now` is the clock port's reading in epoch
11
+ # seconds, `today` the day it falls in, whole days since the epoch in UTC.
12
+ ANSWERS = {
13
+ now: ->(registry) { Ports::Clock.now(registry) },
14
+ today: ->(registry) { Ports::Clock.now(registry) / 86_400 }
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ # Fills each outside fact the declaration `needs` and the caller left out. A value the caller
20
+ # supplied is kept, so a test or a back-fill can name its own time.
21
+ #
22
+ # @param declaring [#needs, #attributes] the command or query being run
23
+ # @param args [Hash{Symbol => Object}] the arguments the caller passed
24
+ # @param registry [Runtime::Registry] where the clock port is bound
25
+ # @return [Hash{Symbol => Object}] `args`, with each missing needed fact answered
26
+ # @raise [Runtime::WiringError] when the port that answers a fact is not bound exactly once
27
+ def fill(declaring, args, registry:)
28
+ missing = declaring.needs.reject { |fact| args.key?(fact) || args.key?(fact.to_s) }
29
+ return args if missing.empty?
30
+
31
+ args.merge(missing.to_h { |fact| [fact, value(declaring, fact, registry)] })
32
+ end
33
+
34
+ # The answer to one fact, in the shape the declaration's argument of that name takes: a bare
35
+ # Integer for an Integer attribute, else the one-field value object the language declares.
36
+ #
37
+ # @param declaring [#attributes] the command or query
38
+ # @param fact [Symbol] a key of `ANSWERS`
39
+ # @param registry [Runtime::Registry] where the clock port is bound
40
+ # @return [Integer, Hash{Symbol => Integer}] the answer, shaped for its argument
41
+ def value(declaring, fact, registry)
42
+ answer = ANSWERS.fetch(fact).call(registry)
43
+ attribute = declaring.attributes.find { |held| held.name.to_s == fact.to_s }
44
+ attribute&.type.to_s == "Integer" ? answer : { value: answer }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -5,6 +5,7 @@ require_relative "../ports/query/ordering"
5
5
  require_relative "../query_specification/field_path"
6
6
  require_relative "../query_specification/common/comparison"
7
7
  require_relative "errors"
8
+ require_relative "needs"
8
9
  require_relative "reference_hop"
9
10
  require_relative "refusal_wording"
10
11
  require_relative "tenant_scope"
@@ -203,6 +204,7 @@ module Hecks
203
204
  def entity_rows(domain, aggregate, dotted, args)
204
205
  entity_name, query_name = Naming.split_dotted(dotted)
205
206
  entity, declared, list_attr = resolve_entity_query(aggregate, entity_name, query_name)
207
+ args = Needs.fill(declared, args, registry: @registry)
206
208
  declared = TenantScope.apply(declared, args)
207
209
 
208
210
  parent_key = Naming.reference_key(aggregate.hecks_name)
@@ -282,6 +284,7 @@ module Hecks
282
284
  # from field defaults and only refuses when a field has none. `null_vo_argument!`
283
285
  # refuses like any other wrong-shaped value. Command arguments are untouched.
284
286
  def normalize_args(aggregate, declared, args)
287
+ args = Needs.fill(declared, args, registry: @registry)
285
288
  declared.attributes.each_with_object(args.dup) do |attribute, normalized|
286
289
  next unless normalized.key?(attribute.name)
287
290
 
@@ -408,9 +408,9 @@ module Hecks
408
408
  File.expand_path(path.to_s).start_with?("#{GEM_CHAPTER_DIR}/")
409
409
  end
410
410
 
411
- # Two packages can share a chapter name by coincidence (found live: a stale
412
- # vendor/hecksagain fork of Governance/Identity/Deploy, still on 4 apps' load
413
- # paths). Intentional accumulation (several files declaring the same chapter on
411
+ # Two packages can share a chapter name by coincidence (a stale vendored
412
+ # fork of Governance/Identity/Deploy still on an app's load paths).
413
+ # Intentional accumulation (several files declaring the same chapter on
414
414
  # purpose) is distinguished by package root, not file identity, and is left
415
415
  # untouched here — checked once, after every file has loaded.
416
416
  def refuse_cross_package_bluebook_merge!
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require_relative "../rust_build"
6
+ require_relative "wasm"
7
+
8
+ module Hecks
9
+ module RustBuild
10
+ # Builds the Rust host binary for a domain and stages it with the domain's module and IR, the
11
+ # three files a container image copies in.
12
+ #
13
+ # Host.call(["path/to/domain", "--target=aarch64-unknown-linux-gnu", "--stage=build/host"])
14
+ #
15
+ # The host is compiled from the workspace this release carries (`rust/host`), so the binary is
16
+ # the one the installed gem was released with; nothing is fetched. The module and IR come from
17
+ # `Wasm`. `CARGO_TARGET_DIR` names where Cargo writes (the copy's `target/` for a client), so
18
+ # a second build recompiles only what changed. Stages `<domain>-host`, `<domain>.wasm` and
19
+ # `<domain>.ir.json` in `--stage` (default `.hecks/host/<target>/` in the working directory).
20
+ module Host
21
+ # The host crate's binary.
22
+ BINARY = "bootstrap"
23
+
24
+ # The toolchain Cargo runs under, as `Wasm` builds with.
25
+ TOOLCHAIN = "stable"
26
+
27
+ # A Rust target triple: `<arch>-<vendor>-<os>`, with an optional `-<env>`.
28
+ TRIPLE = /\A[a-z0-9_]+(-[a-z0-9_.]+){2,3}\z/
29
+
30
+ module_function
31
+
32
+ # @param argv [Array<String>] the domain's directory, then optional `--target=<triple>` and
33
+ # `--stage=<directory>`
34
+ # @return [Integer] the exit status
35
+ # @raise [Failure] when the toolchain, the target or a step is missing or fails
36
+ def call(argv)
37
+ domain = argv.find { |arg| !arg.start_with?("--") } or
38
+ raise Failure, "usage: hecks build_host <domain> [target=<triple>] [stage=<directory>]"
39
+ target = flag(argv, "target") || host_triple
40
+ require_buildable!(target)
41
+ stage = flag(argv, "stage") || File.join(Dir.pwd, ".hecks", "host", target)
42
+
43
+ status = Wasm.call([domain])
44
+ raise Failure, "project_wasm failed for #{domain}" unless status.zero?
45
+
46
+ binary = compile(target)
47
+ publish(binary, File.basename(domain.chomp("/")), stage)
48
+ 0
49
+ end
50
+
51
+ # @param argv [Array<String>] a tool's arguments
52
+ # @param name [String] a flag's name
53
+ # @return [String, nil] the value of `--<name>=<value>`, nil when absent
54
+ def flag(argv, name)
55
+ argv.filter_map { |arg| arg.delete_prefix("--#{name}=") if arg.start_with?("--#{name}=") }.last
56
+ end
57
+
58
+ # @return [String] the triple the Rust toolchain builds for by default
59
+ # @raise [Failure] when there is no Rust toolchain
60
+ def host_triple
61
+ require_rustup!
62
+ query("rustup", "run", TOOLCHAIN, "rustc", "-vV").to_s[/^host: (\S+)/, 1] or
63
+ raise Failure, "rustup has no #{TOOLCHAIN} toolchain. Install it once with:\n\n " \
64
+ "rustup toolchain install #{TOOLCHAIN}\n\nthen re-run the build."
65
+ end
66
+
67
+ def require_buildable!(target)
68
+ raise Failure, "#{target} is not a Rust target triple (for example aarch64-unknown-linux-gnu)" unless
69
+ TRIPLE.match?(target)
70
+ if target.start_with?("wasm")
71
+ raise Failure, "the host is a server binary; #{target} is a wasm target. Build the module with " \
72
+ "build.build_wasm, or name a native target"
73
+ end
74
+
75
+ require_rustup!
76
+ installed = query("rustup", "target", "list", "--installed", "--toolchain", TOOLCHAIN).to_s.split
77
+ return if installed.include?(target) || target == host_triple
78
+
79
+ raise Failure, <<~MSG
80
+ #{target} isn't installed for the #{TOOLCHAIN} toolchain. Install it once with:
81
+
82
+ rustup target add #{target} --toolchain #{TOOLCHAIN}
83
+
84
+ then re-run the build.
85
+ MSG
86
+ end
87
+
88
+ def require_rustup!
89
+ return if query("rustup", "--version")
90
+
91
+ raise Failure, "rustup isn't installed. Install the Rust toolchain from https://rustup.rs, " \
92
+ "then re-run the build."
93
+ end
94
+
95
+ # `rustup run`, not bare `cargo`, as `Wasm` does. A cross target links with the
96
+ # `<arch>-linux-gnu-gcc` on PATH unless `CARGO_TARGET_<TRIPLE>_LINKER` already names a linker.
97
+ def compile(target)
98
+ host_dir = File.join(RustBuild.rust_dir, "host")
99
+ target_dir = ENV.fetch("CARGO_TARGET_DIR", File.join(host_dir, "target"))
100
+ puts "== cargo build --release --target #{target} --bin #{BINARY} (rust/host) =="
101
+ env = { "CARGO_TARGET_DIR" => target_dir }.merge(linker_for(target))
102
+ RustBuild.command!("rustup", "run", TOOLCHAIN, "cargo", "build", "--release", "--target", target,
103
+ "--bin", BINARY, chdir: host_dir, env: env)
104
+ File.join(target_dir, target, "release", BINARY)
105
+ rescue Failure => e
106
+ raise Failure, "#{e.message}#{cross_hint(target)}"
107
+ end
108
+
109
+ def linker_for(target)
110
+ variable = "CARGO_TARGET_#{target.upcase.tr('-.', '__')}_LINKER"
111
+ return {} if ENV.key?(variable) || target == host_triple
112
+
113
+ candidate = "#{target.split('-').first}-linux-gnu-gcc"
114
+ target.end_with?("linux-gnu") && query(candidate, "--version") ? { variable => candidate } : {}
115
+ end
116
+
117
+ def cross_hint(target)
118
+ return "" if target == host_triple
119
+
120
+ "\n#{target} differs from this machine's #{host_triple}, so linking needs a linker for it: " \
121
+ "put a cross compiler such as #{target.split('-').first}-linux-gnu-gcc on PATH, or set " \
122
+ "CARGO_TARGET_#{target.upcase.tr('-.', '__')}_LINKER"
123
+ end
124
+
125
+ def publish(binary, name, stage)
126
+ dist = File.join(RustBuild.rust_dir, "dist")
127
+ files = { binary => "#{name}-host", File.join(dist, "#{name}.wasm") => "#{name}.wasm",
128
+ File.join(dist, "#{name}.ir.json") => "#{name}.ir.json" }
129
+ files.each_key do |path|
130
+ raise Failure, "the build left no #{path}" unless File.file?(path)
131
+ end
132
+ FileUtils.mkdir_p(stage)
133
+ files.each { |from, to| FileUtils.cp(from, File.join(stage, to)) }
134
+ File.chmod(0o755, File.join(stage, "#{name}-host"))
135
+ puts "staged in #{stage}: #{files.values.join(', ')}"
136
+ end
137
+
138
+ # @return [String, nil] a program's combined output, nil when it is missing or fails
139
+ def query(*command)
140
+ out, status = Open3.capture2e(*command)
141
+ status.success? ? out : nil
142
+ rescue SystemCallError
143
+ nil
144
+ end
145
+ end
146
+ end
147
+ end
@@ -5,12 +5,14 @@ require_relative "../rust_build"
5
5
 
6
6
  module Hecks
7
7
  module RustBuild
8
- # The stdin a compiled domain binary reads: the steps to run, plus the declared argument
9
- # defaults the host would hand its kernel.
8
+ # The stdin a compiled domain binary reads: the steps to run, plus what the host would hand its
9
+ # kernel from the domain's IR.
10
10
  #
11
- # The kernel has no IR of its own, so a command's declared defaults reach it as a top-level
12
- # `"defaults"` table, `{ "Domain::Aggregate.Command" => { "attribute" => value } }`, the same
13
- # one the Rust host builds from the domain's IR. A harness that sends only `"steps"` would
11
+ # The kernel has no IR of its own, so the facts a declaration reaches it with arrive as
12
+ # top-level tables the Rust host builds from the domain's IR: `"defaults"`, a command's declared
13
+ # argument defaults (`{ "Domain::Aggregate.Command" => { "attribute" => value } }`); `"needs"`,
14
+ # the outside facts a command needs; and `"query_needs"`, the same for a query (kept apart: a
15
+ # command and a query may share a qualified name). A harness that sends only `"steps"` would
14
16
  # leave the kernel refusing an argument Ruby fills, so every conformance caller builds its
15
17
  # input here.
16
18
  module KernelInput
@@ -18,11 +20,10 @@ module Hecks
18
20
 
19
21
  # @param domain_path [String] the domain's directory; its basename names the generated module
20
22
  # @param steps [Array<Hash>] the steps to run
21
- # @return [Hash{String => Object}] the input; `"defaults"` is there only when one is declared
23
+ # @return [Hash{String => Object}] the input; each table is there only when it has an entry
22
24
  def build(domain_path, steps)
23
25
  input = { "steps" => steps }
24
- defaults = defaults_for(domain_path)
25
- input["defaults"] = defaults unless defaults.empty?
26
+ tables_for(domain_path).each { |name, table| input[name] = table unless table.empty? }
26
27
  input
27
28
  end
28
29
 
@@ -30,19 +31,52 @@ module Hecks
30
31
  # @return [String] the input as JSON, ready for the binary's stdin
31
32
  def json(domain_path, steps) = JSON.generate(build(domain_path, steps))
32
33
 
33
- # Each command's declared defaults, read from the domain's generated IR.
34
+ # Each command's declared defaults, entity commands included, read from the domain's
35
+ # generated IR. An entity's commands sit under `Domain::Aggregate.Entity[.Entity].Command`.
34
36
  #
35
37
  # @param domain_path [String] the domain's directory
36
38
  # @return [Hash{String => Hash{String => Object}}] empty when the IR is absent or has none
37
- def defaults_for(domain_path)
38
- ir = generated_ir(domain_path) or return {}
39
+ def defaults_for(domain_path) = tables_for(domain_path).fetch("defaults")
40
+
41
+ # The three tables of one domain: `"defaults"`, `"needs"` and `"query_needs"`.
42
+ #
43
+ # @param domain_path [String] the domain's directory
44
+ # @return [Hash{String => Hash}] each table, empty when the IR is absent or declares none
45
+ def tables_for(domain_path)
46
+ tables = { "defaults" => {}, "needs" => {}, "query_needs" => {} }
47
+ ir = generated_ir(domain_path) or return tables
39
48
  domain = ir["name"].to_s
40
- Array(ir["aggregates"]).each_with_object({}) do |aggregate, table|
41
- Array(aggregate["commands"]).each do |command|
42
- held = Array(command["attributes"]).reject { |attribute| attribute["default"].nil? }
43
- .to_h { |attribute| [attribute["name"], attribute["default"]] }
44
- table["#{domain}::#{aggregate['name']}.#{command['name']}"] = held unless held.empty?
45
- end
49
+ Array(ir["aggregates"]).each { |aggregate| collect(aggregate, "#{domain}::#{aggregate['name']}", tables) }
50
+ tables
51
+ end
52
+
53
+ # Adds what `node`'s commands and queries declare, then what each entity nested in it does,
54
+ # under `prefix`.
55
+ #
56
+ # @param node [Hash] an aggregate or entity of the IR
57
+ # @param prefix [String] the verb path down to `node`
58
+ # @param tables [Hash] filled in place
59
+ # @return [void]
60
+ def collect(node, prefix, tables)
61
+ Array(node["commands"]).each do |command|
62
+ verb = "#{prefix}.#{command['name']}"
63
+ held = Array(command["attributes"]).reject { |attribute| attribute["default"].nil? }
64
+ .to_h { |attribute| [attribute["name"], attribute["default"]] }
65
+ tables["defaults"][verb] = held unless held.empty?
66
+ tables["needs"][verb] = needs_of(command) unless Array(command["needs"]).empty?
67
+ end
68
+ Array(node["queries"]).each do |query|
69
+ tables["query_needs"]["#{prefix}.#{query['name']}"] = needs_of(query) unless Array(query["needs"]).empty?
70
+ end
71
+ Array(node["entities"]).each { |entity| collect(entity, "#{prefix}.#{entity['name']}", tables) }
72
+ end
73
+
74
+ # @param declaration [Hash] a command or query of the IR that declares `needs`
75
+ # @return [Array<Hash>] each needed fact with the type of the argument of the same name
76
+ def needs_of(declaration)
77
+ Array(declaration["needs"]).map do |need|
78
+ argument = Array(declaration["attributes"]).find { |attribute| attribute["name"] == need["fact"] }
79
+ { "fact" => need["fact"], "type" => argument ? argument["type"].to_s : "" }
46
80
  end
47
81
  end
48
82