hecks 0.3.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/d1.rb +38 -14
- data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
- data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
- data/lib/hecks/adapters/driven/heki.rb +40 -9
- data/lib/hecks/adapters/driven/lambda.rb +16 -2
- data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
- data/lib/hecks/adapters/driven/postgres.rb +76 -29
- data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +1 -1
- data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
- data/lib/hecks/adapters/driven/sqlite.rb +30 -9
- data/lib/hecks/adapters/driven.rb +19 -1
- data/lib/hecks/behaviors/dsl.rb +29 -0
- data/lib/hecks/behaviors/expectations.rb +62 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
- data/lib/hecks/bluebook/assembly/marks.rb +4 -3
- data/lib/hecks/bluebook/assembly.rb +14 -1
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
- data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
- data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
- data/lib/hecks/bluebook/chapter.rb +21 -11
- data/lib/hecks/bluebook/command.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
- data/lib/hecks/bluebook/dsl/command_builder.rb +144 -3
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
- data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
- data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
- data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
- data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
- data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
- data/lib/hecks/bluebook/expression/projection.json +48 -0
- data/lib/hecks/bluebook/expression/resolver.rb +161 -10
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
- data/lib/hecks/bluebook/meta_validator.rb +103 -13
- data/lib/hecks/bluebook/model_check.rb +147 -6
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/project_register.rb +25 -0
- data/lib/hecks/bluebook/value_object.rb +19 -2
- data/lib/hecks/doc/reference.rb +22 -1
- data/lib/hecks/facade/cli_door.rb +6 -3
- data/lib/hecks/facade/json_door.rb +16 -4
- data/lib/hecks/forms/app.rb +47 -6
- data/lib/hecks/forms/command_form_renderer.rb +1 -1
- data/lib/hecks/forms/field_renderer.rb +11 -4
- data/lib/hecks/forms/html.rb +31 -0
- data/lib/hecks/forms/params.rb +30 -1
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/record_renderer.rb +6 -2
- data/lib/hecks/forms/record_table.rb +6 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
- data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
- data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
- data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
- data/lib/hecks/fuzzing/properties.rb +52 -6
- data/lib/hecks/fuzzing/replay.rb +51 -18
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
- data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
- data/lib/hecks/fuzzing/value_generator.rb +25 -2
- data/lib/hecks/grammar/evolve.rb +33 -0
- data/lib/hecks/grammar/expression.bluebook +8 -5
- data/lib/hecks/grammar/expression_operators.json +497 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
- data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
- data/lib/hecks/language/bluebook/command.bluebook +42 -8
- data/lib/hecks/language/bluebook/entity.bluebook +86 -10
- data/lib/hecks/language/bluebook/policy.bluebook +21 -4
- data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
- data/lib/hecks/language/bluebook/projection.bluebook +6 -6
- data/lib/hecks/language/bluebook/query.bluebook +4 -4
- data/lib/hecks/language/bluebook/shape.bluebook +6 -6
- data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
- data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
- data/lib/hecks/language/oidc.json +20 -0
- data/lib/hecks/language/port.bluebook +30 -2
- data/lib/hecks/naming.rb +54 -1
- data/lib/hecks/ports/access_control.port +7 -2
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.port +6 -2
- data/lib/hecks/ports/agent.rb +1 -1
- data/lib/hecks/ports/authentication.port +4 -2
- data/lib/hecks/ports/authentication.rb +1 -1
- data/lib/hecks/ports/authorization.port +5 -2
- data/lib/hecks/ports/authorization.rb +14 -11
- data/lib/hecks/ports/clock.port +3 -2
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.port +3 -2
- data/lib/hecks/ports/extraction.rb +1 -1
- data/lib/hecks/ports/identity_assignment.port +3 -2
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.port +3 -2
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.port +3 -2
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/execution.rb +6 -1
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
- data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence.rb +1 -1
- data/lib/hecks/ports/projection.rb +20 -0
- data/lib/hecks/projections/diagrams.rb +230 -1
- data/lib/hecks/projections/shape.rb +15 -2
- data/lib/hecks/projector/cli_projector.rb +10 -1
- data/lib/hecks/projector/exporter.rb +19 -2
- data/lib/hecks/query_ir.rb +19 -0
- data/lib/hecks/query_specification/common/null_policy.rb +13 -1
- data/lib/hecks/query_specification/field_path.rb +20 -2
- data/lib/hecks/query_specification/hop_path.rb +7 -5
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +19 -3
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
- data/lib/hecks/runtime/command_interpreter.rb +165 -34
- data/lib/hecks/runtime/command_rules/admissibility.rb +101 -16
- data/lib/hecks/runtime/command_rules/arithmetic.rb +40 -4
- data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
- data/lib/hecks/runtime/command_rules/references.rb +27 -19
- data/lib/hecks/runtime/dependency_planning.rb +66 -5
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +92 -14
- data/lib/hecks/runtime/errors.rb +22 -0
- data/lib/hecks/runtime/identity.rb +30 -2
- data/lib/hecks/runtime/instance.rb +59 -4
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/loader.rb +59 -18
- data/lib/hecks/runtime/query_interpreter.rb +36 -4
- data/lib/hecks/runtime/reaction_invocation.rb +9 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +89 -5
- data/lib/hecks/runtime/refusal_wording.rb +2 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
- data/lib/hecks/runtime/registry/verification.rb +88 -0
- data/lib/hecks/runtime/registry.rb +69 -8
- data/lib/hecks/runtime/saga_interpreter.rb +215 -13
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/value/admission.rb +19 -1
- data/lib/hecks/runtime/value/coercion.rb +75 -10
- data/lib/hecks/runtime.rb +17 -5
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +28 -3
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +49 -37
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
- /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
|
@@ -3,7 +3,16 @@ require_relative "../ir"
|
|
|
3
3
|
|
|
4
4
|
module Hecks
|
|
5
5
|
module Bluebook
|
|
6
|
-
|
|
6
|
+
# `compensates` — a SECOND `DispatchSpec`, shape-identical to this
|
|
7
|
+
# one, naming the command that undoes THIS dispatch specifically
|
|
8
|
+
# (see `ProcessManagerBuilder::HandlerBuilder#dispatch_impl`'s own
|
|
9
|
+
# comment). `nil` for a dispatch with nothing to undo (a pure
|
|
10
|
+
# bookkeeping mark, or one whose own effect is superseded by a later
|
|
11
|
+
# command rather than needing its own compensation). Never nested
|
|
12
|
+
# further — a compensation is not itself compensable; no known
|
|
13
|
+
# corpus need, and ADR 0025's own "a word earns its place by being
|
|
14
|
+
# used" bar would refuse a second level speculatively.
|
|
15
|
+
DispatchSpec = Struct.new(:command_name, :with_spec, :compensates, keyword_init: true) do
|
|
7
16
|
# A Struct already answers to_h; including the mixin puts the
|
|
8
17
|
# DECLARED emission ahead of Struct's own in the ancestry, which
|
|
9
18
|
# is what makes the shape data rather than a method body.
|
|
@@ -11,7 +20,8 @@ module Hecks
|
|
|
11
20
|
|
|
12
21
|
emits_ir(
|
|
13
22
|
command_name: -> { command_name.to_s },
|
|
14
|
-
with_spec: -> { with_spec.map { |key, value| [key.to_s, Bluebook.render_value(value)] } }
|
|
23
|
+
with_spec: -> { with_spec.map { |key, value| [key.to_s, Bluebook.render_value(value)] } },
|
|
24
|
+
compensates: one(:compensates)
|
|
15
25
|
)
|
|
16
26
|
end
|
|
17
27
|
|
|
@@ -35,14 +45,31 @@ module Hecks
|
|
|
35
45
|
# word — so here they are two objects, and a procedure either has a saga or
|
|
36
46
|
# does not.
|
|
37
47
|
#
|
|
38
|
-
# `undoes` is the ordered list of commands the compensation sends
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
# `undoes` is the ordered list of commands the compensation sends — a
|
|
49
|
+
# STATIC PREVIEW, declaration order (`Behaviour::ProcessManager#saga`),
|
|
50
|
+
# not one instance's own runtime history. Per-dispatch compensation
|
|
51
|
+
# (`compensates`, on the step it compensates for) moved most of what
|
|
52
|
+
# a saga undoes off this leg's own hand-written body and onto
|
|
53
|
+
# whichever forward dispatch each one undoes — this reads every
|
|
54
|
+
# declared `compensates` across the WHOLE saga first, then whatever
|
|
55
|
+
# this leg's own hand-written body still lists, for compensation
|
|
56
|
+
# that isn't expressible as "undo command X." WHICH of a declared
|
|
57
|
+
# `compensates` actually fires for one instance, and in what order
|
|
58
|
+
# (newest-first, completed-legs-only), is `SagaInterpreter`'s own
|
|
59
|
+
# dynamic `completed_compensations` — a per-instance runtime fact
|
|
60
|
+
# this declaration-only object could never hold.
|
|
61
|
+
#
|
|
62
|
+
# NAMING COLLISION, ONCE FLAGGED, NOW RESOLVED — `command`'s own
|
|
63
|
+
# `corrects event, reverses: true` (docs/implemented/decisions/0036-
|
|
64
|
+
# corrects-is-an-appended-fact-not-a-rewrite.md) already claimed
|
|
65
|
+
# `reverses` for a different meaning: auto-deriving a command's OWN
|
|
66
|
+
# corrective mutation from a past EVENT, not a saga's own
|
|
67
|
+
# compensating leg from a past DISPATCH. This feature keeps
|
|
68
|
+
# `reverses` reserved for `corrects` and uses `compensates` for
|
|
69
|
+
# per-dispatch saga compensation instead — a deliberate choice, not
|
|
70
|
+
# an accidental collision.
|
|
71
|
+
Saga = Struct.new(:trigger, :from_state, :to_state, :compensations, keyword_init: true) do
|
|
72
|
+
def undoes = compensations.map(&:command_name)
|
|
46
73
|
|
|
47
74
|
def to_s = "#{trigger} → #{to_state} (#{undoes.join(', ')})"
|
|
48
75
|
end
|
|
@@ -63,7 +90,22 @@ module Hecks
|
|
|
63
90
|
|
|
64
91
|
emits_ir(
|
|
65
92
|
name: :name,
|
|
66
|
-
|
|
93
|
+
# M11 — `&.`, not `.`: a DSL-built process manager always carries
|
|
94
|
+
# a real `correlates_by` (`ProcessManagerBuilder#build` refuses to
|
|
95
|
+
# mint one without it), but the IR class itself defaults it to
|
|
96
|
+
# `nil` and is what `Assembly::Build`'s `:identity` reader
|
|
97
|
+
# (`value&.to_sym`) round-trips against. A bare `.to_s` mapped
|
|
98
|
+
# that absent case to `""`, indistinguishable on the wire from a
|
|
99
|
+
# real empty name and read back as the wrong, non-nil `:""`
|
|
100
|
+
# instead of `nil` — the same nil-erasure S1 fixed for
|
|
101
|
+
# `render_value`, one field over. `correlates_by` is always a
|
|
102
|
+
# bare Symbol (`SagaInterpreter` hash-looks-up a payload by it),
|
|
103
|
+
# never a `Literal`-encoded polymorphic value, so this stays a
|
|
104
|
+
# local `&.` rather than routing through `Literal.render` — that
|
|
105
|
+
# would wrap a real value in a leading `:` and break both the
|
|
106
|
+
# `:identity` reader's plain `to_sym` and the pinned golden IR
|
|
107
|
+
# fixtures' bare-string spelling (`"reference.value"`).
|
|
108
|
+
correlates_by: -> { correlates_by&.to_s },
|
|
67
109
|
starts_on: :starts_on,
|
|
68
110
|
ends_on: :ends_on,
|
|
69
111
|
states: :states,
|
|
@@ -16,6 +16,7 @@ module Hecks
|
|
|
16
16
|
|
|
17
17
|
def initialize
|
|
18
18
|
@entries = {}
|
|
19
|
+
@tenant_directories = Hash.new { |hash, key| hash[key] = [] }
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def fetch(address) = entries.fetch(address.to_s)
|
|
@@ -25,6 +26,7 @@ module Hecks
|
|
|
25
26
|
|
|
26
27
|
def register(bluebooks, registry, dispatcher, directory)
|
|
27
28
|
bluebooks.each do |bluebook|
|
|
29
|
+
refuse_unless_safe_for_second_tenant!(bluebook, registry, directory)
|
|
28
30
|
world = registry.world(bluebook.name)
|
|
29
31
|
realm = world&.realm
|
|
30
32
|
raise MissingRealm, "#{bluebook.name} in #{directory} has no world realm" if realm.to_s.empty?
|
|
@@ -62,6 +64,29 @@ module Hecks
|
|
|
62
64
|
|
|
63
65
|
private
|
|
64
66
|
|
|
67
|
+
# THE ACTUAL "more than one tenant" MOMENT — Runtime::TenantCheck's
|
|
68
|
+
# own header names this table as the one place real multitenancy
|
|
69
|
+
# happens: the SAME on-disk directory (one domain, one
|
|
70
|
+
# `persisted_by` binding) registering a SECOND time, under a
|
|
71
|
+
# different realm, into this SAME shared route table. A directory's
|
|
72
|
+
# FIRST registration is never refused here — nothing shares its
|
|
73
|
+
# data yet, so a plain single-tenant deployment on an ordinary
|
|
74
|
+
# adapter (Postgres, no schema story) still boots exactly as
|
|
75
|
+
# before. Only the SECOND (and any later) registration of that
|
|
76
|
+
# same directory is refused, and refused before this call adds its
|
|
77
|
+
# routes to the table — so a leaking tenant's requests never
|
|
78
|
+
# become reachable through `Router#resolve` in the first place.
|
|
79
|
+
#
|
|
80
|
+
# Keyed on `directory` + `bluebook.name` only, not realm or
|
|
81
|
+
# `dispatcher` — two tenants share the identical on-disk domain,
|
|
82
|
+
# differing only by which realm/environment overlay booted it.
|
|
83
|
+
def refuse_unless_safe_for_second_tenant!(bluebook, registry, directory)
|
|
84
|
+
key = [directory, bluebook.name]
|
|
85
|
+
seen_before = @tenant_directories.key?(key) && @tenant_directories[key].any?
|
|
86
|
+
Runtime::TenantCheck.refuse_unless_tenant_capable!(registry, bluebook.name) if seen_before
|
|
87
|
+
@tenant_directories[key] << registry
|
|
88
|
+
end
|
|
89
|
+
|
|
65
90
|
def current?(bluebook, world)
|
|
66
91
|
bluebook.version.nil? || world.latest == bluebook.version
|
|
67
92
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "behaviour/value_object"
|
|
2
|
+
require_relative "expression/ast_json"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Bluebook
|
|
@@ -28,9 +29,25 @@ module Hecks
|
|
|
28
29
|
emits_ir(
|
|
29
30
|
name: :hecks_name,
|
|
30
31
|
attributes: many(:attributes),
|
|
31
|
-
|
|
32
|
+
# `ast:` — a JSON-serializable rendering of the SAME predicate
|
|
33
|
+
# `canonical` already spells as text, alongside it rather than
|
|
34
|
+
# replacing it (`canonical` stays the human-facing/doctest-facing
|
|
35
|
+
# form; parsing it back would just re-derive what `ast` already
|
|
36
|
+
# states directly). Ground truth and the full reasoning:
|
|
37
|
+
# `Expression::AstJson`'s own header — built for `rust/host`'s
|
|
38
|
+
# own mint-time invariant check (`reference_validate.rs`), which
|
|
39
|
+
# has no kernel crate to parse `canonical` with.
|
|
40
|
+
invariants: -> { invariants.map { |rule| { description: rule.description, canonical: rule.canonical, ast: Expression::AstJson.emit_predicate(rule.canonical) } } },
|
|
32
41
|
closed_set: :closed_set?,
|
|
33
|
-
|
|
42
|
+
# THE FIELD NAME IS STRINGIFIED, NEVER THE VALUE. A `member` row can
|
|
43
|
+
# hold any of the scalar types an attribute declares — `Integer 84`
|
|
44
|
+
# (`StatementFrequency#retention_months`, statements.bluebook), not
|
|
45
|
+
# only `String` — and `value.to_s` used to erase that on the way
|
|
46
|
+
# out, so `84` and `"84"` (a member some other row might
|
|
47
|
+
# legitimately spell as text) became indistinguishable once they
|
|
48
|
+
# reached `to_h`. The declared name still moves (`field.to_s`) —
|
|
49
|
+
# that half was never a Ruby object with a type to lose.
|
|
50
|
+
members: -> { members.map { |member| member.map { |field, value| [field.to_s, value] } } }
|
|
34
51
|
)
|
|
35
52
|
|
|
36
53
|
class << self
|
data/lib/hecks/doc/reference.rb
CHANGED
|
@@ -257,7 +257,8 @@ module Hecks
|
|
|
257
257
|
"guides" => guide_index(root),
|
|
258
258
|
"reference" => reference_index(root),
|
|
259
259
|
"tools" => tool_table(root),
|
|
260
|
-
"corpus" => corpus_roster(root)
|
|
260
|
+
"corpus" => corpus_roster(root),
|
|
261
|
+
"diagrams" => diagram_showcase(root)
|
|
261
262
|
}
|
|
262
263
|
end
|
|
263
264
|
|
|
@@ -311,6 +312,26 @@ module Hecks
|
|
|
311
312
|
text.length > 140 ? "#{text[0, 137]}..." : text
|
|
312
313
|
end
|
|
313
314
|
|
|
315
|
+
# ONE REAL, COMMITTED FILE, READ FRESH — not re-derived from a boot
|
|
316
|
+
# (this module never requires `hecks/projections/diagrams`, and
|
|
317
|
+
# shouldn't just to draw one example). `docs/generated/diagrams/`
|
|
318
|
+
# is already held to the declaration by `spec/diagrams_spec.rb`'s
|
|
319
|
+
# own drift check; this just quotes its own output, so the two
|
|
320
|
+
# can't independently drift from each other either — a stale
|
|
321
|
+
# Order_lifecycle.mmd fails THAT spec long before this one runs.
|
|
322
|
+
def diagram_showcase(root)
|
|
323
|
+
lifecycle = File.read(File.join(root, "docs/generated/diagrams/pizzas/Order_lifecycle.mmd")).strip
|
|
324
|
+
<<~MARKDOWN.strip
|
|
325
|
+
`bin/project_diagrams` reads a booted domain's own declaration and draws it as Mermaid — nine kinds so far: `<Name>_lifecycle.mmd`, `relationships.mmd`, `dispatch.mmd`, `roles.mmd`, `ports.mmd`, `read_models.mmd`, `<Name>_surface.mmd` (what a command does, and what it writes), `<Name>_saga.mmd`, and `frameworks.mmd`. Nothing hand-drawn — the same reason a domain is data at all. Order's own lifecycle, straight off the bluebook above:
|
|
326
|
+
|
|
327
|
+
```mermaid
|
|
328
|
+
#{lifecycle}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The full set for every domain in this checkout — `examples/pizzas`, `examples/banking` — lives in [`docs/generated/diagrams/`](docs/generated/diagrams/), held to the declaration by `spec/diagrams_spec.rb` the same drift-refusing way this page is held to its own source.
|
|
332
|
+
MARKDOWN
|
|
333
|
+
end
|
|
334
|
+
|
|
314
335
|
def corpus_roster(root)
|
|
315
336
|
dirs = Dir.glob(File.join(root, "examples/*/")).sort
|
|
316
337
|
lines = dirs.filter_map do |dir|
|
|
@@ -33,10 +33,13 @@ module Hecks
|
|
|
33
33
|
|
|
34
34
|
pairs.each_with_object({}) do |pair, args|
|
|
35
35
|
path, value = split(pair)
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
# key? first, never `||` — full names whichever spelling (the
|
|
37
|
+
# bare path, or its one-argument expansion) actually declares
|
|
38
|
+
# this option, and the lookup below must hold to that same
|
|
39
|
+
# decision rather than re-guessing which one exists.
|
|
40
|
+
full = options.key?(path) ? path : expand(path, options)
|
|
41
|
+
argument = options.key?(full) ? options[full] : raise(Runtime::NotFound, unknown(path, options.keys))
|
|
38
42
|
|
|
39
|
-
full = options.key?(path) ? path : expand(path, options)
|
|
40
43
|
next append(args, full.split("."), cast(value, argument[:type])) if argument[:list]
|
|
41
44
|
|
|
42
45
|
bury(args, full.split("."), cast(value, argument[:type]))
|
|
@@ -81,12 +81,24 @@ module Hecks
|
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
# A URL segment or a JSON body's "command" field, checked against what
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
84
|
+
# a `Handle` can actually dispatch — NOT `klass.commands`, which is
|
|
85
|
+
# `AggregateDoor`'s own door-level list and includes the one creating
|
|
86
|
+
# command too (`aggregate_door.rb`'s `commands` singleton method maps
|
|
87
|
+
# every `ir.commands`, full stop). A `Handle` only ever defines
|
|
88
|
+
# singleton methods for the NON-creating ones
|
|
89
|
+
# (`Handle#define_verb_methods`, `@ir.commands.reject(&:creates?)`) —
|
|
90
|
+
# the creating command lives on the aggregate class itself, dispatched
|
|
91
|
+
# through `.creating_command` above, not through a `Handle` in hand.
|
|
92
|
+
# Accepting a creating-command name here let it past this gate clean,
|
|
93
|
+
# only to blow up as a raw `NoMethodError` the moment a caller tried
|
|
94
|
+
# `handle.public_send(name, **args)`, instead of the 404 this method
|
|
95
|
+
# promises. Filtering `reject(&:creates?)` here, the same filter
|
|
96
|
+
# `Handle` itself applies, is what keeps "accepted here" and
|
|
97
|
+
# "dispatchable there" the same set.
|
|
87
98
|
def validate_command!(klass, name)
|
|
88
99
|
wanted = name.to_s
|
|
89
|
-
|
|
100
|
+
dispatchable = klass.ir.commands.reject(&:creates?).map { |command| "#{Naming.snake(command.hecks_name)}!" }
|
|
101
|
+
return wanted if dispatchable.include?(wanted)
|
|
90
102
|
|
|
91
103
|
raise Runtime::NotFound, "#{klass.ir.hecks_name} declares no command named #{wanted.inspect}"
|
|
92
104
|
end
|
data/lib/hecks/forms/app.rb
CHANGED
|
@@ -81,9 +81,24 @@ module Hecks
|
|
|
81
81
|
raise RouteNotFound, "#{domain.inspect} is not exposed by this app — declared chapters: #{@exposed.join(', ')}"
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
+
# H12 (docs/audits/2026-08-10-main-bug-audit.md) — splitting on the
|
|
85
|
+
# FIRST "." truncated any identity value containing a dot (an email
|
|
86
|
+
# `identified_by { email.address }`, a decimal-ish reference — an
|
|
87
|
+
# aggregate's identity is free-form unless its value object declares
|
|
88
|
+
# a `pattern:`, see S3 in the same audit) at its own first dot, so
|
|
89
|
+
# `reference.value=c.1` 404'd everywhere: detail page, JSON view, and
|
|
90
|
+
# its own index-table link. Only a LITERAL trailing ".html"/".json"
|
|
91
|
+
# now counts as a format — every other dot in the segment is just
|
|
92
|
+
# part of the identity. An identity that itself happens to end in
|
|
93
|
+
# exactly ".html" or ".json" is still ambiguous with a real format
|
|
94
|
+
# suffix (the same tension any extension-based content-negotiation
|
|
95
|
+
# scheme has), but that was already true before this fix and is not
|
|
96
|
+
# this bug.
|
|
84
97
|
def split_format(segment)
|
|
85
|
-
|
|
86
|
-
[
|
|
98
|
+
segment = segment.to_s
|
|
99
|
+
return [Regexp.last_match(1), Regexp.last_match(2)] if segment =~ /\A(.*)\.(html|json)\z/
|
|
100
|
+
|
|
101
|
+
[segment, "json"]
|
|
87
102
|
end
|
|
88
103
|
|
|
89
104
|
# ---- home -------------------------------------------------------
|
|
@@ -122,6 +137,21 @@ module Hecks
|
|
|
122
137
|
aggregate = find_aggregate(chapter, aggregate_name)
|
|
123
138
|
domain = chapter.name
|
|
124
139
|
|
|
140
|
+
# L11 (docs/audits/2026-08-10-main-bug-audit.md) — a record's own
|
|
141
|
+
# id is free-form (S3) and can collide with one of its own
|
|
142
|
+
# aggregate's command/query names ("Close", "Overdrawn", ...).
|
|
143
|
+
# A GET for such an id must still be able to reach that RECORD's
|
|
144
|
+
# own detail page when a record with that literal id actually
|
|
145
|
+
# exists — checking the verb first (the previous order) meant a
|
|
146
|
+
# record unlucky enough to be named after a real verb could never
|
|
147
|
+
# be viewed again. POST never means "view a record" at all
|
|
148
|
+
# (`record_route` only ever answers GET), so command submission
|
|
149
|
+
# there is unambiguous and is left to match the verb first, same
|
|
150
|
+
# as before.
|
|
151
|
+
if request.get? && (instance = @registry.repository(domain, aggregate).find(verb_or_id))
|
|
152
|
+
return record_route(request, domain, aggregate, verb_or_id, format, instance: instance)
|
|
153
|
+
end
|
|
154
|
+
|
|
125
155
|
if (command = aggregate.command(verb_or_id))
|
|
126
156
|
return command_route(request, domain, aggregate, command, format)
|
|
127
157
|
end
|
|
@@ -157,7 +187,9 @@ module Hecks
|
|
|
157
187
|
def submit_command(request, domain, aggregate, command, action)
|
|
158
188
|
raw, envelope = submitted_command(request, aggregate, command)
|
|
159
189
|
result = @dispatcher.dispatch("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}", **envelope)
|
|
160
|
-
|
|
190
|
+
# L12 — the id is free-form (S3), so it must be percent-encoded as
|
|
191
|
+
# a path segment here, not just interpolated raw.
|
|
192
|
+
redirect("/#{domain}/#{aggregate.hecks_name}/#{Escape.path(result.id)}.html")
|
|
161
193
|
rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
|
|
162
194
|
status = e.is_a?(Runtime::NotFound) ? 404 : 422
|
|
163
195
|
command_form(domain, aggregate, command, action, status: status, values: raw, error: e)
|
|
@@ -221,14 +253,23 @@ module Hecks
|
|
|
221
253
|
args = Params.extract(fields, asked)
|
|
222
254
|
rows = @dispatcher.query("#{domain}::#{aggregate.hecks_name}.#{query.hecks_name}", **args)
|
|
223
255
|
[rows.map { |row| Record.new(row[:id], row.reject { |k, _| k == :id }) }, nil]
|
|
224
|
-
|
|
256
|
+
# L10 (docs/audits/2026-08-10-main-bug-audit.md) — `Params.extract`
|
|
257
|
+
# (params.rb's `extract_list`) reads a list-of-value-object line as
|
|
258
|
+
# JSON (the honest fallback for a multi-attribute list element this
|
|
259
|
+
# prototype's textarea doesn't build a second widget for). A caller
|
|
260
|
+
# who types a non-JSON line into that field raises `JSON::ParserError`
|
|
261
|
+
# BEFORE dispatch ever sees it — both command submission paths
|
|
262
|
+
# already rescue it (`submit_command`, `command_json`); this one
|
|
263
|
+
# didn't, so a malformed list-of-VO query 500'd instead of showing
|
|
264
|
+
# the same 422 every other bad-input path shows.
|
|
265
|
+
rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
|
|
225
266
|
[nil, e]
|
|
226
267
|
end
|
|
227
268
|
|
|
228
|
-
def record_route(request, domain, aggregate, id, format)
|
|
269
|
+
def record_route(request, domain, aggregate, id, format, instance: nil)
|
|
229
270
|
return respond(405, "text/plain", "GET only") unless request.get?
|
|
230
271
|
|
|
231
|
-
instance
|
|
272
|
+
instance ||= @registry.repository(domain, aggregate).find(id)
|
|
232
273
|
return not_found(aggregate, id, format) unless instance
|
|
233
274
|
# id LAST — same reasoning as the other JSON-serializing call
|
|
234
275
|
# sites in this file (see aggregate_route's own comment).
|
|
@@ -57,7 +57,7 @@ module Hecks
|
|
|
57
57
|
def self.header(domain, aggregate, command)
|
|
58
58
|
<<~HTML
|
|
59
59
|
<h1>#{Escape.html("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}")}</h1>
|
|
60
|
-
#{command.role ? %(<span class="badge role">role: #{Escape.html(command.role)}</span>) : ''}
|
|
60
|
+
#{command.role ? %(<span class="badge role" title="Declared on the command; this prototype dispatches with no caller bound, so the check does not run.">role: #{Escape.html(command.role)} (not enforced here)</span>) : ''}
|
|
61
61
|
#{command.creates? ? %(<span class="badge">creates a new #{Escape.html(aggregate.hecks_name)}</span>) : ''}
|
|
62
62
|
#{command.goal ? %(<p class="goal">#{Escape.html(command.goal)}</p>) : ''}
|
|
63
63
|
#{givens_callout(command)}
|
|
@@ -156,13 +156,20 @@ module Hecks
|
|
|
156
156
|
# an existing record's own state hands back a NESTED hash instead
|
|
157
157
|
# (`{amount: {cents: 1050}}`). Flat wins when both would answer,
|
|
158
158
|
# since only the raw form is ever what the caller actually typed.
|
|
159
|
-
|
|
160
|
-
|
|
159
|
+
# `key?` decides which spelling answers, at every step below —
|
|
160
|
+
# never `||`, which would treat a genuinely-held `false` the
|
|
161
|
+
# same as an absent key and fall through to `nil`.
|
|
162
|
+
str = path.to_s
|
|
163
|
+
return values[str] if values.key?(str)
|
|
161
164
|
|
|
162
|
-
path.
|
|
165
|
+
sym = path.to_sym
|
|
166
|
+
return values[sym] if values.key?(sym)
|
|
167
|
+
|
|
168
|
+
str.split(".").reduce(values) do |acc, segment|
|
|
163
169
|
break nil unless acc.is_a?(Hash)
|
|
164
170
|
|
|
165
|
-
|
|
171
|
+
seg_sym = segment.to_sym
|
|
172
|
+
acc.key?(seg_sym) ? acc[seg_sym] : acc[segment]
|
|
166
173
|
end
|
|
167
174
|
end
|
|
168
175
|
end
|
data/lib/hecks/forms/html.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
1
3
|
module Hecks
|
|
2
4
|
module Forms
|
|
3
5
|
# Hand-rolled, on purpose — the repo has no ERB anywhere and no template
|
|
@@ -25,6 +27,35 @@ module Hecks
|
|
|
25
27
|
# call site reads "this value fills an attribute" rather than repeating
|
|
26
28
|
# the same escaping and leaving the reader to check they match.
|
|
27
29
|
def self.attr(value) = html(value)
|
|
30
|
+
|
|
31
|
+
# L12 (docs/audits/2026-08-10-main-bug-audit.md) — safe as a
|
|
32
|
+
# query-string VALUE. `html`/`attr` guard against the value becoming
|
|
33
|
+
# markup, but say nothing about it staying inside the URL syntax
|
|
34
|
+
# position it was placed in: an aggregate's identity is free-form
|
|
35
|
+
# unless its value object declares a `pattern:` (see S3 in the same
|
|
36
|
+
# audit), so `&`, `+`, `?`, `#`, and `/` are all otherwise legal id
|
|
37
|
+
# characters, and each would corrupt an href/Location built by naive
|
|
38
|
+
# interpolation (a stray `&` smuggles a second query parameter, `#`
|
|
39
|
+
# truncates the path at a fragment, `/` splits the path into an
|
|
40
|
+
# extra segment, ...). Percent-encodes via
|
|
41
|
+
# `application/x-www-form-urlencoded` (`+` for space) — correct ONLY
|
|
42
|
+
# for a query-string value (query_form_renderer.rb's `quick_links`,
|
|
43
|
+
# record_renderer.rb's `?to=`). For a URL PATH segment use `path`
|
|
44
|
+
# below instead — `+` is a literal plus there, not an escaped space,
|
|
45
|
+
# so this method would corrupt any id containing a space. Callers
|
|
46
|
+
# still wrap the ASSEMBLED href/Location in `attr` (or `html`) as
|
|
47
|
+
# usual — this only covers the id's own component, not the
|
|
48
|
+
# surrounding markup.
|
|
49
|
+
def self.url(value) = URI.encode_www_form_component(value.to_s)
|
|
50
|
+
|
|
51
|
+
# Same guard as `url`, for a URL PATH segment instead of a
|
|
52
|
+
# query-string value. `encode_www_form_component` renders space as
|
|
53
|
+
# `+`, which is only meaningful inside a query string — in a path
|
|
54
|
+
# segment `+` is a literal plus, so an id like "John Smith" would
|
|
55
|
+
# round-trip to "John+Smith" and 404 against the real id "John
|
|
56
|
+
# Smith". Reuse the same percent-encoding and just correct that one
|
|
57
|
+
# character back to `%20`.
|
|
58
|
+
def self.path(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
|
|
28
59
|
end
|
|
29
60
|
|
|
30
61
|
# A tiny attribute-hash -> string helper, shared by every renderer in
|
data/lib/hecks/forms/params.rb
CHANGED
|
@@ -41,15 +41,44 @@ module Hecks
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# A path-prefix collision: one field named (say) "price" alongside
|
|
45
|
+
# another named "price.cents" implies "price" is BOTH a scalar leaf
|
|
46
|
+
# and the parent of a nested group — the two can never coexist in
|
|
47
|
+
# the same result hash. Depending on which pair `each_with_object`
|
|
48
|
+
# reaches first, the naive walk below used to fail in one of two
|
|
49
|
+
# ways: a scalar planted first left `acc[segment] ||= {}` seeing a
|
|
50
|
+
# truthy non-Hash and reusing IT as `node`, so the next `node[leaf] =
|
|
51
|
+
# value` blew up with a raw `TypeError` from calling `String#[]=`
|
|
52
|
+
# with a Symbol key; a scalar planted AFTER the nested group instead
|
|
53
|
+
# sailed through `node[leaf] = value` and silently clobbered the
|
|
54
|
+
# entire nested hash with the scalar, losing every sibling under it
|
|
55
|
+
# with no error at all. Both directions are checked explicitly here
|
|
56
|
+
# so either order raises the SAME clear `ArgumentError` instead of a
|
|
57
|
+
# confusing crash or silent data loss — this is the family of error
|
|
58
|
+
# every command/query submission path in app.rb already rescues into
|
|
59
|
+
# a 422 (`ArgumentError` sits right alongside the domain refusals in
|
|
60
|
+
# every one of those rescue clauses).
|
|
44
61
|
def self.nest(pairs)
|
|
45
62
|
pairs.each_with_object({}) do |(path, value), result|
|
|
46
63
|
segments = path.to_s.split(".").map(&:to_sym)
|
|
47
64
|
leaf = segments.pop
|
|
48
|
-
node = segments.reduce(result)
|
|
65
|
+
node = segments.reduce(result) do |acc, segment|
|
|
66
|
+
existing = acc[segment]
|
|
67
|
+
raise nesting_collision(path) if existing && !existing.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
acc[segment] ||= {}
|
|
70
|
+
end
|
|
71
|
+
raise nesting_collision(path) if node[leaf].is_a?(Hash)
|
|
72
|
+
|
|
49
73
|
node[leaf] = value
|
|
50
74
|
end
|
|
51
75
|
end
|
|
52
76
|
|
|
77
|
+
def self.nesting_collision(path)
|
|
78
|
+
ArgumentError.new("#{path.inspect} conflicts with another field at the same path — " \
|
|
79
|
+
"one names it as a plain value and another as a nested group")
|
|
80
|
+
end
|
|
81
|
+
|
|
53
82
|
SKIP = Object.new.freeze
|
|
54
83
|
private_constant :SKIP
|
|
55
84
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Forms
|
|
3
|
+
# bin/present's own `-p`/`--port` reader, pulled out of the script so
|
|
4
|
+
# it can be driven directly instead of only through a real server
|
|
5
|
+
# boot. Two spellings the previous inline version got wrong:
|
|
6
|
+
#
|
|
7
|
+
# --port=8080 the equals form — the old `ARGV.each_cons(2)` scan
|
|
8
|
+
# only ever recognized "--port", "8080" as TWO
|
|
9
|
+
# separate argv entries, so this spelling matched
|
|
10
|
+
# nothing and silently fell through to the default.
|
|
11
|
+
# -p abc a non-numeric value — the old code did `.to_i` on
|
|
12
|
+
# whatever followed unconditionally, so a typo
|
|
13
|
+
# quietly became port 0 (Rackup/WEBrick's actual
|
|
14
|
+
# behavior for `Port: 0` is to bind an EPHEMERAL
|
|
15
|
+
# port — arguably useful on purpose elsewhere, but
|
|
16
|
+
# never what a mistyped `-p abc` meant to ask for).
|
|
17
|
+
#
|
|
18
|
+
# Returns `[port, nil]` on a clean parse (falling back to `default`
|
|
19
|
+
# when neither spelling appears at all) or `[nil, message]` when an
|
|
20
|
+
# explicit port was given but isn't a real port number — the caller
|
|
21
|
+
# decides what to do with a refusal (bin/present aborts on it).
|
|
22
|
+
module PortArgument
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def parse(argv, default: 4567)
|
|
26
|
+
equals = argv.find { |arg| arg.start_with?("--port=") }
|
|
27
|
+
return resolve(equals.split("=", 2).last) if equals
|
|
28
|
+
|
|
29
|
+
index = argv.each_index.find { |i| %w[-p --port].include?(argv[i]) }
|
|
30
|
+
return [default, nil] unless index
|
|
31
|
+
|
|
32
|
+
resolve(argv[index + 1])
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def resolve(value)
|
|
36
|
+
return [nil, "-p/--port requires a value"] if value.nil? || value.empty?
|
|
37
|
+
return [nil, "-p/--port must be a whole number, got #{value.inspect}"] unless value.match?(/\A\d+\z/)
|
|
38
|
+
|
|
39
|
+
port = value.to_i
|
|
40
|
+
return [nil, "-p/--port must be between 1 and 65535, got #{port}"] unless (1..65_535).cover?(port)
|
|
41
|
+
|
|
42
|
+
[port, nil]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -76,8 +76,12 @@ module Hecks
|
|
|
76
76
|
return "<p><em>No commands act on an existing #{Escape.html(aggregate.hecks_name)}.</em></p>" if commands.empty?
|
|
77
77
|
|
|
78
78
|
items = commands.map do |cmd|
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
# L12 — the id is free-form (S3): percent-encoded as the query
|
|
80
|
+
# VALUE (a raw `&` here would smuggle a second bogus query
|
|
81
|
+
# parameter), then the assembled href is attribute-escaped as
|
|
82
|
+
# usual.
|
|
83
|
+
href = "/#{domain}/#{aggregate.hecks_name}/#{cmd.hecks_name}.html?to=#{Escape.url(id)}"
|
|
84
|
+
%(<li><a href="#{Escape.attr(href)}"><span>#{Escape.html(cmd.hecks_name)}</span><span class="kind">#{Escape.html(cmd.goal.to_s)}</span></a></li>)
|
|
81
85
|
end
|
|
82
86
|
%(<ul class="verb-list">#{items.join}</ul>)
|
|
83
87
|
end
|
|
@@ -41,7 +41,12 @@ module Hecks
|
|
|
41
41
|
|
|
42
42
|
def self.row(instance, aggregate, cols, domain)
|
|
43
43
|
cells = cols.map { |name| "<td>#{Escape.html(cell(instance, name))}</td>" }.join
|
|
44
|
-
|
|
44
|
+
# L12 — the id is free-form (S3): percent-encoded as the path
|
|
45
|
+
# segment, HTML-escaped as the link text, and the assembled href
|
|
46
|
+
# is itself attribute-escaped (belt-and-suspenders — nothing else
|
|
47
|
+
# in `href` is untrusted, but this matches the convention used
|
|
48
|
+
# everywhere else an href is built from parts).
|
|
49
|
+
href = "/#{domain}/#{aggregate.hecks_name}/#{Escape.path(instance.id)}.html"
|
|
45
50
|
"<tr><td><a href=\"#{Escape.attr(href)}\">#{Escape.html(instance.id)}</a></td>#{cells}</tr>"
|
|
46
51
|
end
|
|
47
52
|
|