hecks 2.5.1 → 2.6.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.
@@ -0,0 +1,153 @@
1
+ module Hecks
2
+ module Bluebook
3
+ module ModelCheck
4
+ # The opt-in `client` profile: refuses the bluebook constructs this
5
+ # repository already knows produce a silently wrong answer, so a client
6
+ # domain cannot reach one without being told.
7
+ #
8
+ # ## What it is not
9
+ #
10
+ # It fixes nothing. Each rule below names a bug that is still open and
11
+ # stops a domain reaching it unnoticed; when the bug is fixed the rule
12
+ # goes with it. `spec/model_check_client_profile_spec.rb` holds a probe
13
+ # per rule that runs the buggy code and fails the moment the bug stops
14
+ # reproducing, naming the rule to delete.
15
+ #
16
+ # ## The rules
17
+ #
18
+ # - `:client_group_by_row_drop` — a `group_by` whose fields do not include
19
+ # the grouped aggregate's whole identity. Rows sharing a key path are
20
+ # collapsed to the first, on every adapter (docs/decisions/0061).
21
+ # - `:client_native_read_model` — a read model the SQLite projection
22
+ # answers natively, where every other path answers it in process
23
+ # (docs/1.0-readiness.md, "Known gaps at 1.0", item 2).
24
+ # - `:client_dotted_compute_source` — an era translation `compute` whose
25
+ # source is a dotted path, which never fires
26
+ # (`Translation::RuleCompiler.compile_compute`).
27
+ module ClientProfile
28
+ # Adapters whose Ruby class implements `query_read_model`, which is what
29
+ # makes `ReadModelInterpreter#project` take the native path. The spec
30
+ # checks this list against the adapter classes themselves.
31
+ NATIVE_READ_MODEL_ADAPTERS = %w[SqliteProjection].freeze
32
+
33
+ GROUP_BY_TRACKER = "docs/decisions/0061-query-dsl-aggregation-count-sum-group-by.md".freeze
34
+ NATIVE_READ_MODEL_TRACKER = "docs/1.0-readiness.md (Known gaps at 1.0, item 2)".freeze
35
+ DOTTED_COMPUTE_TRACKER = "Translation::RuleCompiler.compile_compute, and the pending example " \
36
+ "\"applies the SQL of a compute whose source is a dotted member\" in " \
37
+ "spec/adapters/driven/postgres_era/migration_data_safety_spec.rb".freeze
38
+
39
+ module_function
40
+
41
+ # Runs every client-profile rule over one chapter and its translations.
42
+ #
43
+ # @param bluebook [Bluebook::Chapter] the assembled chapter to check
44
+ # @param hecksagon [Bluebook::Hecksagon, nil] the chapter's sibling wiring file; without
45
+ # one no aggregate is projected, so the native-read-model rule has nothing to find
46
+ # @param translations [Array<Bluebook::Translation>] the data translations declared for
47
+ # this chapter's domain
48
+ # @return [Array<ModelCheck::Finding>] one error-severity finding per construct refused
49
+ def call(bluebook, hecksagon: nil, translations: [])
50
+ bluebook.read_models.flat_map do |model|
51
+ group_by_findings(bluebook, model) + native_read_model_findings(model, hecksagon)
52
+ end + dotted_compute_findings(translations)
53
+ end
54
+
55
+ # Refuses a `group_by` that cannot prove each of its key paths names one row.
56
+ #
57
+ # The interpreter's `nest` keeps `stripped.first` per key path, whichever adapter
58
+ # supplied the rows, because a `group_by` model never reaches the native path. Grouping
59
+ # by the aggregate's whole identity is the one shape that cannot collide.
60
+ #
61
+ # @param bluebook [Bluebook::Chapter] the chapter the read model is declared in
62
+ # @param model [Bluebook::ReadModel] the read model to inspect
63
+ # @return [Array<ModelCheck::Finding>] one finding, or `[]` when `model` declares no
64
+ # `group_by` or groups by its aggregate's whole identity
65
+ def group_by_findings(bluebook, model)
66
+ return [] if model.group_by.empty? || groups_by_identity?(bluebook, model)
67
+
68
+ [finding(:client_group_by_row_drop, model.name,
69
+ "group_by #{model.group_by_fields.map(&:inspect).join(', ')} does not cover the grouped " \
70
+ "aggregate's whole identity, so rows sharing a key path are silently reduced to the " \
71
+ "first. Tracked in #{GROUP_BY_TRACKER}.")]
72
+ end
73
+
74
+ # Refuses a read model that the SQLite projection would answer natively.
75
+ #
76
+ # Mirrors the fork in `ReadModelInterpreter#project`: a rooted model with no `group_by`,
77
+ # `count` or `median` reads through `read_repository`, which is a projection repository
78
+ # only for an aggregate with a `projected_by` bind. Whether that projection is current
79
+ # is only known at run time (`Registry#projection_current?`), so a model eligible here
80
+ # can also silently run in process.
81
+ #
82
+ # @param model [Bluebook::ReadModel] the read model to inspect
83
+ # @param hecksagon [Bluebook::Hecksagon, nil] the wiring holding the `projected_by` binds
84
+ # @return [Array<ModelCheck::Finding>] one finding, or `[]` when the model cannot be
85
+ # pushed down
86
+ def native_read_model_findings(model, hecksagon)
87
+ return [] unless hecksagon && native_eligible?(model)
88
+
89
+ bind = hecksagon.binds_for(model.reference_target, Ports::Projection::VERB).first
90
+ return [] unless bind && NATIVE_READ_MODEL_ADAPTERS.include?(bind.adapter.to_s)
91
+
92
+ [finding(:client_native_read_model, model.name,
93
+ "#{model.reference_target} is projected_by #{bind.adapter.inspect}, so this read model " \
94
+ "is answered by SQL when the projection is current and by the in-process loop when it " \
95
+ "is not, and nothing checks the two agree. Tracked in #{NATIVE_READ_MODEL_TRACKER}.")]
96
+ end
97
+
98
+ # Refuses a translation `compute` whose source names a member of a value object.
99
+ #
100
+ # `compile_compute` guards the compute with `__s ? '<from>'`, a top-level key test, so a
101
+ # dotted source never matches, the mint succeeds and the record keeps its old value.
102
+ #
103
+ # @param translations [Array<Bluebook::Translation>] the translations to inspect
104
+ # @return [Array<ModelCheck::Finding>] one finding per dotted-source compute
105
+ def dotted_compute_findings(translations)
106
+ translations.flat_map do |translation|
107
+ translation.aggregates.flat_map do |aggregate|
108
+ aggregate.computes.select { |compute| compute.from.to_s.include?(".") }.map do |compute|
109
+ finding(:client_dotted_compute_source, "#{translation.domain}::#{aggregate.name}",
110
+ "compute #{compute.from.to_s.inspect} has a dotted source, which the compiled SQL " \
111
+ "never matches, so the mint succeeds and leaves the old value in place. " \
112
+ "Tracked in #{DOTTED_COMPUTE_TRACKER}.")
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ # Reports whether a model's shape sends it down the native path in `#project`.
119
+ #
120
+ # @param model [Bluebook::ReadModel] the read model to inspect
121
+ # @return [Boolean] true for a rooted model declaring no `group_by`, `count` or `median`
122
+ def native_eligible?(model)
123
+ !model.reference_target.nil? && model.group_by.empty? && !model.count? && !model.median_field
124
+ end
125
+
126
+ # Reports whether a `group_by` names every identity path of the aggregate it groups.
127
+ #
128
+ # @param bluebook [Bluebook::Chapter] the chapter the read model is declared in
129
+ # @param model [Bluebook::ReadModel] a read model that declares a `group_by`
130
+ # @return [Boolean] false when the many-side aggregate is not a top-level aggregate or
131
+ # declares no identity, since uniqueness cannot then be shown
132
+ def groups_by_identity?(bluebook, model)
133
+ target = model.aggregate_heads.find { |head| head[:many] }
134
+ aggregate = target && bluebook.aggregate(target[:aggregate])
135
+ return false unless aggregate
136
+
137
+ identity = Array(aggregate.identity_heads).map(&:to_sym)
138
+ !identity.empty? && (identity - model.group_by_fields.map(&:to_sym)).empty?
139
+ end
140
+
141
+ # Builds one error-severity finding of this profile.
142
+ #
143
+ # @param kind [Symbol] the finding's kind, one of the `:client_*` rules above
144
+ # @param subject [String] the read model or `Domain::Aggregate` the finding is about
145
+ # @param message [String] what was found, why it is refused, and where it is tracked
146
+ # @return [ModelCheck::Finding] an `:error` finding
147
+ def finding(kind, subject, message)
148
+ ModelCheck::Finding.new(kind: kind, severity: :error, subject: subject, message: message)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -1,4 +1,5 @@
1
1
  require "hecks/vocabulary"
2
+ require_relative "model_check/client_profile"
2
3
 
3
4
  module Hecks
4
5
  module Bluebook
@@ -100,6 +101,9 @@ module Hecks
100
101
  # was needed or made.
101
102
  }.freeze
102
103
 
104
+ # The profiles `call` accepts; `nil` is the default, unprofiled run.
105
+ PROFILES = %i[client].freeze
106
+
103
107
  module_function
104
108
 
105
109
  # Runs every static model check over `bluebook` and returns what it
@@ -137,9 +141,23 @@ module Hecks
137
141
  # target, raising `rust_reserved_name` findings to error
138
142
  # @param strict [Boolean] whether to raise every `rust_reserved_name`
139
143
  # finding to error regardless of `rust_target`
144
+ # @param profile [Symbol, nil] `:client` adds `ClientProfile`'s error
145
+ # findings for constructs known to answer wrongly without refusing;
146
+ # `nil` (the default) adds nothing and leaves every other finding as
147
+ # it was
148
+ # @param translations [Array<Bluebook::Translation>] the data
149
+ # translations declared for this chapter's domain, read only by the
150
+ # `:client` profile — they live on the registry, not the chapter, so
151
+ # the caller supplies them the way it supplies `hecksagon`
140
152
  # @return [Array<Finding>] every finding this bluebook triggers,
141
153
  # across its lifecycles, sagas, policies, and Rust-reserved names
142
- def call(bluebook, hecksagon: nil, known_domains: nil, global_emitted_events: nil, rust_target: false, strict: false)
154
+ # @raise [ArgumentError] if `profile` is neither `nil` nor `:client`
155
+ def call(bluebook, hecksagon: nil, known_domains: nil, global_emitted_events: nil, rust_target: false, strict: false,
156
+ profile: nil, translations: [])
157
+ unless profile.nil? || PROFILES.include?(profile)
158
+ raise ArgumentError, "unknown profile #{profile.inspect} (known: #{PROFILES.inspect})"
159
+ end
160
+
143
161
  findings = []
144
162
  bluebook.aggregates.each do |aggregate|
145
163
  findings.concat(lifecycle_findings(aggregate, aggregate))
@@ -152,6 +170,7 @@ module Hecks
152
170
  findings.concat(rust_reserved_name_findings(domain_name: bluebook.name,
153
171
  aggregate_names: bluebook.aggregates.map(&:hecks_name),
154
172
  rust_target: rust_target, strict: strict))
173
+ findings.concat(ClientProfile.call(bluebook, hecksagon: hecksagon, translations: translations)) if profile == :client
155
174
  findings
156
175
  end
157
176
 
@@ -93,13 +93,17 @@ module Hecks
93
93
  # `adapter: :postgres_era`; ignored by every other adapter
94
94
  # @param schema [String, nil] the schema name, required only for
95
95
  # `adapter: :postgres_era`; ignored by every other adapter
96
+ # @param scratch [Hash{Symbol => String}] `:database` and/or `:schema` for
97
+ # `adapter: :postgres` to use instead of the shared fuzz scratch schema, so a caller
98
+ # such as `bin/bench` never drops the schema a concurrent `bin/fuzz` is using;
99
+ # ignored by every other adapter
96
100
  # @yield [String] the freshly rebound copy's root directory, ready to boot
97
101
  # @yieldreturn [Object] anything; becomes this method's own return value
98
102
  # @return [Object] whatever the given block returns
99
103
  # @raise [ArgumentError] if `adapter` is not `:memory`, `:sqlite`, `:postgres`, or
100
104
  # `:postgres_era`, or if `adapter: :postgres_era` is given without both
101
105
  # `database:` and `schema:`
102
- def call(domain_path, adapter: :memory, database: nil, schema: nil)
106
+ def call(domain_path, adapter: :memory, database: nil, schema: nil, scratch: {})
103
107
  Dir.mktmpdir("hecks-fuzz") do |tmp|
104
108
  copy = File.join(tmp, File.basename(domain_path))
105
109
  copy_dereferencing(domain_path, copy)
@@ -107,7 +111,7 @@ module Hecks
107
111
  case adapter
108
112
  when :memory then rebind_to_memory!(copy)
109
113
  when :sqlite then rebind_to_sqlite!(copy)
110
- when :postgres then rebind_to_postgres!(copy)
114
+ when :postgres then rebind_to_postgres!(copy, **scratch)
111
115
  when :postgres_era then rebind_to_postgres_era!(copy, database: database, schema: schema)
112
116
  else raise ArgumentError,
113
117
  "unknown fuzz adapter #{adapter.inspect} — :memory, :sqlite, :postgres, or :postgres_era"
@@ -262,15 +266,20 @@ module Hecks
262
266
  FUZZ_POSTGRES_SCHEMA = "hecks_fuzz".freeze
263
267
 
264
268
  # Rewrites every `.hecksagon` in the copy to bind through Postgres, against the
265
- # shared `FUZZ_POSTGRES_DATABASE`/`FUZZ_POSTGRES_SCHEMA` scratch schema.
269
+ # shared `FUZZ_POSTGRES_DATABASE`/`FUZZ_POSTGRES_SCHEMA` scratch schema unless the
270
+ # caller names its own, which lets `bin/bench` run without touching the fuzzer's.
266
271
  #
267
272
  # @param copy [String] the isolated copy's root directory
273
+ # @param database [String, nil] the scratch database, `FUZZ_POSTGRES_DATABASE` when nil
274
+ # @param schema [String, nil] the scratch schema, `FUZZ_POSTGRES_SCHEMA` when nil
268
275
  # @return [void]
269
- def rebind_to_postgres!(copy)
276
+ def rebind_to_postgres!(copy, database: nil, schema: nil)
270
277
  require "pg"
278
+ database ||= FUZZ_POSTGRES_DATABASE
279
+ schema ||= FUZZ_POSTGRES_SCHEMA
271
280
  rewrite_bindings!(copy, "Postgres")
272
281
  strip_translations!(copy)
273
- ensure_fuzz_schema!
282
+ ensure_fuzz_schema!(database, schema)
274
283
 
275
284
  # One `.world` per directory a `.hecksagon` actually lives in, not
276
285
  # one at `copy`'s own root — `Folder#load_domain` resolves a
@@ -278,34 +287,17 @@ module Hecks
278
287
  # recursively (`Folder#load_each`); a domain can hold several
279
288
  # `Hecks.hecksagon "<Name>" do ... end` sibling blocks in that one
280
289
  # file (banking.hecksagon declares "Banking", "Governance", and
281
- # "Identity" together), so every name found in one `.hecksagon`
282
- # file gets bundled into one `.world` written beside it, not
283
- # scattered by name.
284
- Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
285
- names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten.uniq
286
- next if names.empty?
287
-
288
- world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres.world")
289
- File.write(world_path, names.map do |name|
290
- <<~WORLD
291
- Hecks.world "#{name}" do
292
- persisted_by("Postgres") do
293
- database "#{FUZZ_POSTGRES_DATABASE}"
294
- schema "#{FUZZ_POSTGRES_SCHEMA}"
295
- end
290
+ # "Identity" together), so every name found in one directory
291
+ # is bundled into one `.world` written there, not scattered by name.
292
+ write_worlds!(copy, "hecks_fuzz_postgres.world") do |name|
293
+ <<~WORLD
294
+ Hecks.world "#{name}" do
295
+ persisted_by("Postgres") do
296
+ database "#{database}"
297
+ schema "#{schema}"
296
298
  end
297
- WORLD
298
- end.join("\n"))
299
- end
300
-
301
- # Any pre-existing `.world` this copy shipped with (a real
302
- # deployment's own connection string) is now redundant with — and
303
- # would conflict with, `Registry#add_world`'s own header on
304
- # loading the same domain name twice — the fresh one just
305
- # written above, so it goes the same way `rebind_to_memory!`
306
- # already sends every `.world` in a Memory-mode boot.
307
- Dir.glob(File.join(copy, "**", "*.world")).each do |path|
308
- File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres.world"
299
+ end
300
+ WORLD
309
301
  end
310
302
  end
311
303
 
@@ -318,8 +310,10 @@ module Hecks
318
310
  # recreated on every call, which is what actually isolates one
319
311
  # ephemeral boot's data from the next.
320
312
  #
313
+ # @param database [String] the scratch database to create if missing
314
+ # @param schema [String] the schema to drop and recreate inside it
321
315
  # @return [void]
322
- def ensure_fuzz_schema!
316
+ def ensure_fuzz_schema!(database = FUZZ_POSTGRES_DATABASE, schema = FUZZ_POSTGRES_SCHEMA)
323
317
  # `Adapters::Postgres#initialize` opens one real `PG::Connection`
324
318
  # per aggregate and never explicitly closes it — fine for a
325
319
  # process that boots once and runs, exactly what every other
@@ -342,17 +336,18 @@ module Hecks
342
336
  # something to route around by connecting less carefully.
343
337
  GC.start
344
338
 
345
- unless @fuzz_database_ready
339
+ @fuzz_databases_ready ||= {}
340
+ unless @fuzz_databases_ready[database]
346
341
  admin = PG.connect(dbname: "postgres")
347
342
  exists = admin.exec_params(
348
- "SELECT 1 FROM pg_database WHERE datname = $1", [FUZZ_POSTGRES_DATABASE]
343
+ "SELECT 1 FROM pg_database WHERE datname = $1", [database]
349
344
  ).ntuples.positive?
350
- admin.exec(%(CREATE DATABASE "#{FUZZ_POSTGRES_DATABASE}")) unless exists
345
+ admin.exec(%(CREATE DATABASE "#{database}")) unless exists
351
346
  admin.close
352
- @fuzz_database_ready = true
347
+ @fuzz_databases_ready[database] = true
353
348
  end
354
349
 
355
- db = PG.connect(dbname: FUZZ_POSTGRES_DATABASE)
350
+ db = PG.connect(dbname: database)
356
351
  # **Quiet on purpose** — same as `Adapters::Postgres.connect_for`'s
357
352
  # own `SET client_min_messages`: a `DROP SCHEMA ... CASCADE` that
358
353
  # actually has something to drop (every boot after the first)
@@ -361,7 +356,7 @@ module Hecks
361
356
  # output under a wall of "drop cascades to table ..." on every
362
357
  # single ephemeral boot.
363
358
  db.exec("SET client_min_messages = warning")
364
- quoted = db.quote_ident(FUZZ_POSTGRES_SCHEMA)
359
+ quoted = db.quote_ident(schema)
365
360
  db.exec("DROP SCHEMA IF EXISTS #{quoted} CASCADE")
366
361
  db.exec("CREATE SCHEMA #{quoted}")
367
362
  db.close
@@ -416,9 +411,9 @@ module Hecks
416
411
  ensure_postgres_era_schema!(database: database, schema: schema)
417
412
 
418
413
  # Same one-`.world`-per-`.hecksagon`-directory shape `rebind_to_
419
- # postgres!` already uses, for the identical reason (`Folder#
420
- # load_domain` globs `*.world` non-recursively) — see that
421
- # method's own comment on `world_path` above. `PostgresEra`
414
+ # postgres!` uses, for the identical reason (`Folder#
415
+ # load_domain` globs `*.world` non-recursively) — see
416
+ # `write_worlds!`. `PostgresEra`
422
417
  # additionally takes `schema:` (postgres_era.rb's own "shared-
423
418
  # instance isolation" comment): the caller-supplied throwaway
424
419
  # schema is what actually isolates this one ephemeral boot from
@@ -444,36 +439,54 @@ module Hecks
444
439
  # database it is about to throw away; the one-line warning
445
440
  # PostgresEra prints per boot under the opt-in is the honest
446
441
  # price. Inert on a machine whose ambient user is ordinary.
447
- # One world file per directory, every hecksagon name in that
448
- # directory — not one write per *.hecksagon file. context_map.hecksagon
449
- # sits beside the domain file; a second File.write to the same
450
- # hecks_fuzz_postgres_era.world would drop the first file's names
451
- # (found live: ConcurrentDispatchUnboundFixture + Governance).
452
- worlds_by_dir = Hash.new { |h, k| h[k] = [] }
442
+ write_worlds!(copy, "hecks_fuzz_postgres_era.world") do |name|
443
+ <<~WORLD
444
+ Hecks.world "#{name}" do
445
+ persisted_by("PostgresEra") do
446
+ database "#{database}"
447
+ schema "#{schema}"
448
+ allow_superuser true
449
+ end
450
+ end
451
+ WORLD
452
+ end
453
+ end
454
+
455
+ # Writes one `.world` file per directory that holds a `.hecksagon`, naming every
456
+ # `Hecks.hecksagon` block found in that directory, and deletes every other `.world`
457
+ # in the copy.
458
+ #
459
+ # One world file per directory, every hecksagon name in that directory — never one
460
+ # write per `*.hecksagon` file. A domain's `context_map.hecksagon` sits beside its
461
+ # main hecksagon (the `qa` domain declares Governance in one and QualityControl in
462
+ # the other), and a second `File.write` to the same world path would drop the first
463
+ # file's names, leaving that domain bound to Postgres with no `database` to open.
464
+ #
465
+ # Any `.world` the copy shipped with (a real deployment's own connection string) is
466
+ # redundant with, and would conflict with (`Registry#add_world`'s own header, on
467
+ # loading the same domain name twice), the fresh one written here, so it goes the same
468
+ # way `rebind_to_memory!` sends every `.world` in a Memory-mode boot.
469
+ #
470
+ # @param copy [String] the isolated copy's root directory
471
+ # @param world_file [String] the basename of the world file written in each directory
472
+ # @yield [name] builds the world text for one hecksagon name
473
+ # @yieldparam name [String] a domain name a `Hecks.hecksagon` block in the directory declares
474
+ # @yieldreturn [String] the `Hecks.world` block for that name
475
+ # @return [void]
476
+ def write_worlds!(copy, world_file, &world_for)
477
+ names_by_dir = Hash.new { |hash, dir| hash[dir] = [] }
453
478
  Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
454
479
  names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten
455
- worlds_by_dir[File.dirname(hecksagon_path)].concat(names)
480
+ names_by_dir[File.dirname(hecksagon_path)].concat(names)
456
481
  end
457
- worlds_by_dir.each do |dir, names|
458
- names = names.uniq
482
+ names_by_dir.each do |dir, names|
459
483
  next if names.empty?
460
484
 
461
- world_path = File.join(dir, "hecks_fuzz_postgres_era.world")
462
- File.write(world_path, names.map do |name|
463
- <<~WORLD
464
- Hecks.world "#{name}" do
465
- persisted_by("PostgresEra") do
466
- database "#{database}"
467
- schema "#{schema}"
468
- allow_superuser true
469
- end
470
- end
471
- WORLD
472
- end.join("\n"))
485
+ File.write(File.join(dir, world_file), names.uniq.map(&world_for).join("\n"))
473
486
  end
474
487
 
475
488
  Dir.glob(File.join(copy, "**", "*.world")).each do |path|
476
- File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres_era.world"
489
+ File.delete(path) unless File.basename(path) == world_file
477
490
  end
478
491
  end
479
492
 
@@ -0,0 +1,160 @@
1
+ require "socket"
2
+
3
+ module Hecks
4
+ # The startup gate for the stdio MCP servers in `bin/` (`hecks_mcp_door`,
5
+ # `hecks_query_ir_mcp`): it refuses to run when the process was set up to
6
+ # speak to anything but a local parent, and it says on stderr what the
7
+ # process does and does not protect.
8
+ #
9
+ # ## Why a gate and not a comment
10
+ #
11
+ # Both servers have no authentication. What keeps a stranger away from
12
+ # them is that only a process that can spawn them can write to their
13
+ # stdin. That holds until someone wraps one in `socat TCP-LISTEN:... EXEC:`
14
+ # or an `inetd`-style listener, which hands the process a network socket
15
+ # as stdin/stdout without changing a line of the server. The gate turns
16
+ # that setup into a refusal at startup instead of an exposure nobody
17
+ # noticed.
18
+ #
19
+ # ## What it checks
20
+ #
21
+ # - **Arguments** — none, except `--stdio`. A `--port` or `--http` flag is
22
+ # refused rather than ignored, so a caller that believes it started a
23
+ # network server finds out immediately.
24
+ # - **Environment** — no `HECKS_MCP_*` variable except
25
+ # `HECKS_MCP_TRANSPORT=stdio`. The servers take no configuration, so an
26
+ # unknown option in that namespace fails closed.
27
+ # - **Descriptors** — stdin and stdout must not be internet-protocol sockets
28
+ # (`AF_INET` or `AF_INET6`). A pipe, a file, a terminal and a Unix-domain socket all pass;
29
+ # the last matters because some MCP clients spawn servers over a
30
+ # `socketpair`, which is local to the machine.
31
+ #
32
+ # ## What it does not do
33
+ #
34
+ # It does not authenticate anyone and it cannot see a proxy that copies
35
+ # bytes from a network socket into an ordinary pipe. It closes the
36
+ # configurations that announce themselves; the ADR on MCP authentication
37
+ # (`docs/decisions/0062-mcp-servers-need-real-authentication-before-any-network-transport.md`)
38
+ # covers what a network transport would need.
39
+ module McpStdioGuard
40
+ ACCEPTED_ARGS = %w[--stdio].freeze
41
+ ENV_PREFIX = "HECKS_MCP_".freeze
42
+ ACCEPTED_ENV = { "HECKS_MCP_TRANSPORT" => "stdio" }.freeze
43
+ EXIT_STATUS = 2
44
+
45
+ # The lines every stdio server prints, ahead of its own notes.
46
+ COMMON_NOTES = [
47
+ "stdio only. This server has no authentication and must not be exposed over a network.",
48
+ "Whoever can write to this process's stdin can make every call it accepts."
49
+ ].freeze
50
+
51
+ module_function
52
+
53
+ # Lists every reason this process should not start as a stdio server.
54
+ #
55
+ # @param argv [Array<String>] the command-line arguments
56
+ # @param env [Hash{String => String}] the process environment
57
+ # @param stdin [IO] the stream requests arrive on
58
+ # @param stdout [IO] the stream responses leave on
59
+ # @return [Array<String>] one message per violation; empty when the process is
60
+ # set up for stdio
61
+ def violations(argv: ARGV, env: ENV, stdin: $stdin, stdout: $stdout)
62
+ argument_violations(argv) + environment_violations(env) + descriptor_violations(stdin, stdout)
63
+ end
64
+
65
+ # Builds the warning printed at startup.
66
+ #
67
+ # @param server [String] the server's name, used as the line prefix
68
+ # @param notes [Array<String>] server-specific lines added after `COMMON_NOTES`
69
+ # @return [Array<String>] the banner lines, each prefixed with `server`
70
+ def banner(server:, notes: [])
71
+ (COMMON_NOTES + notes).map { |line| "#{server}: #{line}" }
72
+ end
73
+
74
+ # Refuses to continue on a non-stdio setup.
75
+ #
76
+ # A server calls this before it requires anything heavy, so a misconfigured start
77
+ # fails in milliseconds and before any protocol byte is answered. Everything goes
78
+ # to `stderr`: stdout carries the MCP protocol, and one stray line there corrupts
79
+ # the client's framing.
80
+ #
81
+ # @param server [String] the server's name, used as the line prefix
82
+ # @param argv [Array<String>] the command-line arguments
83
+ # @param env [Hash{String => String}] the process environment
84
+ # @param stdin [IO] the stream requests arrive on
85
+ # @param stdout [IO] the stream responses leave on
86
+ # @param stderr [IO] where the refusal is written
87
+ # @return [void]
88
+ # @raise [SystemExit] with status `EXIT_STATUS` when `violations` is not empty
89
+ def enforce_stdio!(server:, argv: ARGV, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr)
90
+ found = violations(argv: argv, env: env, stdin: stdin, stdout: stdout)
91
+ refuse!(server, found, stderr) unless found.empty?
92
+ end
93
+
94
+ # Prints the startup warning to `stderr`, never to stdout.
95
+ #
96
+ # @param server [String] the server's name, used as the line prefix
97
+ # @param notes [Array<String>] server-specific warning lines
98
+ # @param stderr [IO] where the warning is written
99
+ # @return [void]
100
+ def warn!(server:, notes: [], stderr: $stderr)
101
+ banner(server: server, notes: notes).each { |line| stderr.puts(line) }
102
+ end
103
+
104
+ # Enforces stdio, then prints the warning.
105
+ #
106
+ # @param server [String] the server's name, used as the line prefix
107
+ # @param notes [Array<String>] server-specific warning lines
108
+ # @param stderr [IO] where the refusal or the warning is written
109
+ # @param checks [Hash{Symbol => Object}] `argv:`, `env:`, `stdin:` and `stdout:`, as
110
+ # for `enforce_stdio!`
111
+ # @return [void]
112
+ # @raise [SystemExit] with status `EXIT_STATUS` when the setup is not stdio
113
+ def start!(server:, notes: [], stderr: $stderr, **checks)
114
+ enforce_stdio!(server: server, stderr: stderr, **checks)
115
+ warn!(server: server, notes: notes, stderr: stderr)
116
+ end
117
+
118
+ # @api private
119
+ def refuse!(server, found, stderr)
120
+ found.each { |message| stderr.puts("#{server}: refusing to start: #{message}") }
121
+ stderr.puts("#{server}: this server speaks MCP over stdio only.")
122
+ exit(EXIT_STATUS)
123
+ end
124
+
125
+ # @api private
126
+ def argument_violations(argv)
127
+ (argv - ACCEPTED_ARGS).map do |arg|
128
+ "argument #{arg.inspect} is not accepted (only #{ACCEPTED_ARGS.join(', ')}); network options are refused, not ignored"
129
+ end
130
+ end
131
+
132
+ # @api private
133
+ def environment_violations(env)
134
+ env.select { |name, value| name.start_with?(ENV_PREFIX) && ACCEPTED_ENV[name] != value }.map do |name, value|
135
+ "environment #{name}=#{value.inspect} is not accepted; the servers take no #{ENV_PREFIX}* options " \
136
+ "except #{ACCEPTED_ENV.map { |k, v| "#{k}=#{v}" }.join(', ')}"
137
+ end
138
+ end
139
+
140
+ # @api private
141
+ def descriptor_violations(stdin, stdout)
142
+ { "stdin" => stdin, "stdout" => stdout }.select { |_, io| ip_socket?(io) }.map do |label, _|
143
+ "#{label} is a network (IP) socket, so this process is being served over a network"
144
+ end
145
+ end
146
+
147
+ # A socket whose address cannot be read is refused: it cannot be shown to be local.
148
+ #
149
+ # @api private
150
+ def ip_socket?(io)
151
+ return false unless io.stat.socket?
152
+
153
+ socket = Socket.for_fd(io.fileno)
154
+ socket.autoclose = false # the descriptor belongs to `io`
155
+ socket.local_address.ip?
156
+ rescue SystemCallError, IOError
157
+ true
158
+ end
159
+ end
160
+ end
data/lib/hecks/version.rb CHANGED
@@ -12,5 +12,5 @@ module Hecks
12
12
  # it there, or even in the consuming Gemfile, never closed the gap,
13
13
  # because gemspec evaluation happens before anything Bundler
14
14
  # resolves is actually loadable yet.
15
- VERSION = "2.5.1".freeze
15
+ VERSION = "2.6.0".freeze
16
16
  end
data/lib/hecks.rb CHANGED
@@ -31,6 +31,7 @@ require_relative "hecks/projections"
31
31
  require_relative "hecks/facade/cli_door"
32
32
  require_relative "hecks/facade/cli_runner"
33
33
  require_relative "hecks/storehouse"
34
+ require_relative "hecks/mcp_stdio_guard"
34
35
  require_relative "hecks/framework"
35
36
  require_relative "hecks/corpus"
36
37
  require_relative "hecks/embryonaut_bluebook"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.1
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-26 00:00:00.000000000 Z
11
+ date: 2026-09-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -94,6 +94,17 @@ files:
94
94
  - lib/hecks/behaviors/ir.rb
95
95
  - lib/hecks/behaviors/rspec.rb
96
96
  - lib/hecks/behaviors/runner.rb
97
+ - lib/hecks/bench.rb
98
+ - lib/hecks/bench/cli.rb
99
+ - lib/hecks/bench/environment.rb
100
+ - lib/hecks/bench/postgres_probe.rb
101
+ - lib/hecks/bench/report.rb
102
+ - lib/hecks/bench/ruby_runner.rb
103
+ - lib/hecks/bench/run.rb
104
+ - lib/hecks/bench/rust_runner.rb
105
+ - lib/hecks/bench/stats.rb
106
+ - lib/hecks/bench/suite.rb
107
+ - lib/hecks/bench/workload.rb
97
108
  - lib/hecks/bluebook.rb
98
109
  - lib/hecks/bluebook/aggregate.rb
99
110
  - lib/hecks/bluebook/assembly.rb
@@ -174,6 +185,7 @@ files:
174
185
  - lib/hecks/bluebook/meta_validator/translation_judge.rb
175
186
  - lib/hecks/bluebook/meta_validator/world_judge.rb
176
187
  - lib/hecks/bluebook/model_check.rb
188
+ - lib/hecks/bluebook/model_check/client_profile.rb
177
189
  - lib/hecks/bluebook/pattern_subset.rb
178
190
  - lib/hecks/bluebook/policy.rb
179
191
  - lib/hecks/bluebook/process_manager.rb
@@ -302,6 +314,7 @@ files:
302
314
  - lib/hecks/language/world/wiring.bluebook
303
315
  - lib/hecks/language/world/world.bluebook
304
316
  - lib/hecks/literal.rb
317
+ - lib/hecks/mcp_stdio_guard.rb
305
318
  - lib/hecks/naming.rb
306
319
  - lib/hecks/ports.rb
307
320
  - lib/hecks/ports/access_control.port