hecks 2.7.0 → 2.8.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 (150) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/memory.rb +1 -1
  4. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  5. data/lib/hecks/adapters/driven/prism.rb +1 -1
  6. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  7. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  8. data/lib/hecks/behaviors/expectations.rb +2 -2
  9. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  10. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  11. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  12. data/lib/hecks/bluebook/assembly.rb +1 -1
  13. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  14. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  15. data/lib/hecks/bluebook/capabilities.rb +2 -2
  16. data/lib/hecks/bluebook/chapter.rb +1 -1
  17. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  18. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  19. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  21. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  22. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  23. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
  26. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  27. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  28. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  29. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  30. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  31. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  32. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  33. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  34. data/lib/hecks/bluebook/model_check.rb +12 -13
  35. data/lib/hecks/cache_dir.rb +103 -0
  36. data/lib/hecks/cli/document.rb +104 -0
  37. data/lib/hecks/cli/ir.rb +47 -0
  38. data/lib/hecks/cli/mcp.rb +32 -0
  39. data/lib/hecks/cli/mcp_door.rb +461 -0
  40. data/lib/hecks/cli/model_check.rb +270 -0
  41. data/lib/hecks/cli/project_cli.rb +123 -0
  42. data/lib/hecks/cli/project_diagrams.rb +43 -0
  43. data/lib/hecks/cli/run.rb +219 -0
  44. data/lib/hecks/cli/smoke_test.rb +59 -0
  45. data/lib/hecks/cli/stores.rb +54 -0
  46. data/lib/hecks/cli.rb +166 -0
  47. data/lib/hecks/facade/handle.rb +2 -2
  48. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  50. data/lib/hecks/mcp_door_scope.rb +176 -0
  51. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  52. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
  53. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  54. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  55. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  56. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  57. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  58. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
  59. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  60. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  61. data/lib/hecks/projections/deploy/lambda.rb +3 -3
  62. data/lib/hecks/projections/reference.rb +7 -1
  63. data/lib/hecks/projector/exporter.rb +4 -4
  64. data/lib/hecks/projector/target.rb +1 -1
  65. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  66. data/lib/hecks/release/runner/commands.rb +68 -0
  67. data/lib/hecks/release/runner/console.rb +52 -0
  68. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  69. data/lib/hecks/release/runner/git.rb +48 -0
  70. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  71. data/lib/hecks/release/runner/preflight.rb +119 -0
  72. data/lib/hecks/release/runner/published.rb +55 -0
  73. data/lib/hecks/release/runner/tagger.rb +87 -0
  74. data/lib/hecks/release/runner.rb +244 -0
  75. data/lib/hecks/router/namespace_installer.rb +1 -1
  76. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  77. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  78. data/lib/hecks/runtime/entity_element.rb +1 -1
  79. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  80. data/lib/hecks/runtime/invocation.rb +1 -1
  81. data/lib/hecks/runtime/loader.rb +1 -1
  82. data/lib/hecks/runtime/outbox.rb +1 -1
  83. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  84. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  85. data/lib/hecks/runtime/reference_hop.rb +4 -4
  86. data/lib/hecks/runtime/registry/verification.rb +11 -11
  87. data/lib/hecks/runtime/registry.rb +8 -4
  88. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  89. data/lib/hecks/runtime/value/coercion.rb +3 -3
  90. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  91. data/lib/hecks/storehouse.rb +18 -12
  92. data/lib/hecks/version.rb +1 -1
  93. data/lib/hecks/vocabulary.rb +5 -0
  94. data/lib/hecks.rb +6 -1
  95. metadata +28 -58
  96. data/lib/hecks/bench/cli.rb +0 -74
  97. data/lib/hecks/bench/environment.rb +0 -100
  98. data/lib/hecks/bench/postgres_probe.rb +0 -41
  99. data/lib/hecks/bench/report.rb +0 -116
  100. data/lib/hecks/bench/ruby_runner.rb +0 -151
  101. data/lib/hecks/bench/run.rb +0 -55
  102. data/lib/hecks/bench/rust_runner.rb +0 -165
  103. data/lib/hecks/bench/stats.rb +0 -75
  104. data/lib/hecks/bench/suite.rb +0 -172
  105. data/lib/hecks/bench/workload.rb +0 -137
  106. data/lib/hecks/bench.rb +0 -28
  107. data/lib/hecks/codemod.rb +0 -443
  108. data/lib/hecks/corpus.rb +0 -479
  109. data/lib/hecks/doc/reference.rb +0 -585
  110. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  111. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  112. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  113. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  114. data/lib/hecks/fuzzing/differential.rb +0 -192
  115. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  116. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  117. data/lib/hecks/fuzzing/form_census.rb +0 -285
  118. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  119. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  120. data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
  121. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  122. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  123. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  124. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  125. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  126. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  127. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  128. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  129. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  130. data/lib/hecks/fuzzing/properties.rb +0 -267
  131. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  132. data/lib/hecks/fuzzing/replay.rb +0 -930
  133. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  134. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  135. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  136. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  137. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  138. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  139. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  140. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  141. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  142. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  143. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  144. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  145. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  146. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  147. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  148. data/lib/hecks/fuzzing.rb +0 -23
  149. data/lib/hecks/grammar/evolve.rb +0 -524
  150. data/lib/hecks/query_ir.rb +0 -482
@@ -1,524 +0,0 @@
1
- module Hecks
2
- module Grammar
3
- # The file surgery under bin/evolve: reading and rewriting the
4
- # aggregate-local KeywordSeed/ArgumentSeed rows as text, so a
5
- # proposed word enters the table exactly as a hand would write it
6
- # and an admitted one loses its ceremony (an absent status reads as
7
- # admitted — the grown-column convention).
8
- #
9
- # Text, not IR, on purpose: the syntax table is source, its comments
10
- # and grouping are part of the declaration, and a rewrite that
11
- # round-tripped it through the IR would flatten both. Everything
12
- # here touches only bare `member ` lines inside Keyword's own body
13
- # (S3, ADR 0025 — no `one_of do ... end` wrapper anymore) and leaves
14
- # every other byte alone.
15
- module Evolve
16
- class Refusal < StandardError; end
17
-
18
- module_function
19
-
20
- # bin/evolve's own `--name value` flag reader. Only consumes the
21
- # next argv element as the value when that element doesn't itself
22
- # look like a flag — otherwise `--foo --bar` would swallow `--bar`
23
- # as `--foo`'s value (and `--bar` would then never be seen at
24
- # all), and a value-less `--foo` at the end of argv would bypass
25
- # whatever default `foo` promised instead of falling back to it.
26
- # @param argv [Array<String>] the command-line argument list to scan
27
- # @param name [String] the flag's name, without its leading `--`
28
- # @param default [String, nil] value to use when the flag is absent or has no value
29
- # @return [String, nil] the flag's value, or `default`
30
- def option(argv, name, default = nil)
31
- index = argv.index("--#{name}")
32
- return default unless index
33
-
34
- value = argv[index + 1]
35
- value.nil? || value.start_with?("-") ? default : value
36
- end
37
-
38
- # The snapshot/restore ceremony bin/evolve wraps every mutating
39
- # command in: read every `paths` file, run the block, and put
40
- # every file back exactly as it was if the block raises partway
41
- # through — `rename`'s keyword-row write followed by its
42
- # argument cascade across several files (`cascade_argument_rename`
43
- # above) included, not only a declared gate failing after the
44
- # block has already returned cleanly (bin/evolve's own `guarded`
45
- # still handles that half on its own, since it depends on running
46
- # the gate specs, not on anything this method knows about). A
47
- # clean return leaves the snapshots unused; the caller decides
48
- # from there whether the tree stands.
49
- #
50
- # @param paths [Array<String>] files to snapshot before running the block
51
- # @yield the mutating work to run, restored from snapshot if it raises
52
- # @return [Object] the block's result
53
- # @raise [StandardError] re-raises whatever the block raised, after restoring
54
- # every snapshotted file
55
- def restore_on_raise(paths)
56
- snapshots = paths.to_h { |path| [path, File.read(path)] }
57
- yield
58
- rescue StandardError
59
- snapshots.each { |path, content| File.write(path, content) }
60
- raise
61
- end
62
-
63
- # Every syntax-table file declaring a `KeywordSeed` or `ArgumentSeed`
64
- # value object.
65
- #
66
- # @return [Array<String>] matching `.bluebook` file paths
67
- def syntax_paths
68
- Dir.glob(File.expand_path("../language/**/*.bluebook", __dir__)).select do |path|
69
- source = File.read(path)
70
- source.include?('value_object "KeywordSeed"') || source.include?('value_object "ArgumentSeed"')
71
- end
72
- end
73
-
74
- # Kept as a narrow compatibility door for callers deliberately doing
75
- # single-file surgery. Normal operation uses `syntax_paths` and discovers
76
- # the owning concept from the row itself.
77
- #
78
- # @return [String, nil] the first syntax-table path, or nil when there are none
79
- def syntax_path = syntax_paths.first
80
-
81
- # Resolves the file(s) a call should search or write.
82
- #
83
- # @param path [String, nil] an explicit single file, or nil for every syntax path
84
- # @return [Array<String>] `[path]` when given, else `syntax_paths`
85
- def paths_for(path) = path ? Array(path) : syntax_paths
86
-
87
- # The Keyword one_of's member rows, parsed leniently off the text —
88
- # enough to know each row's (word, context, status), which is all
89
- # the tool ever asks.
90
- #
91
- # @param path [String, nil] an explicit single file to read, or nil for every
92
- # syntax path
93
- # @return [Array<Hash>] one Hash per member row, with `:word`, `:context`,
94
- # `:status` (`"admitted"` when unspelled), and `:was` (nil unless renamed)
95
- # @raise [Refusal] if `path` is given and declares no `KeywordSeed` value object
96
- def keyword_rows(path = nil)
97
- paths_for(path).flat_map do |candidate|
98
- blocks = seed_blocks(File.read(candidate), "KeywordSeed")
99
- raise Refusal, "source declares no KeywordSeed value object" if path && blocks.empty?
100
-
101
- blocks.flat_map do |block|
102
- block.scan(/^\s*member (.+)$/).map do |(cells)|
103
- row = cells.scan(/(\w+): "((?:[^"\\]|\\.)*)"/).to_h
104
- { word: row["word"], context: row["context"],
105
- status: row.fetch("status", "admitted"), was: row["was"] }
106
- end
107
- end
108
- end
109
- end
110
-
111
- # Declares a new, proposed keyword row in the syntax table that owns
112
- # `context` (or `opens`'s own aggregate, for a `File`-context word).
113
- #
114
- # @param word [String] the keyword's spelling
115
- # @param context [String] the grammar context the word is declared in
116
- # @param body [String] the keyword's body shape; `"none"` by default
117
- # @param inner [String] the keyword's inner shape, if any
118
- # @param opens [String] the aggregate concept a `File`-context word opens
119
- # @param fills [String] the field the keyword fills, if any
120
- # @param path [String, nil] an explicit single file to search/write, or nil to
121
- # search every syntax path
122
- # @return [void]
123
- # @raise [Refusal] if `context`.`word` is already declared, or no syntax table
124
- # owns `context`
125
- def propose(word:, context:, body: "none", inner: "", opens: "", fills: "", path: nil)
126
- if keyword_rows(path).any? do |row|
127
- row[:word] == word && row[:context] == context
128
- end
129
- raise Refusal,
130
- "#{context}.#{word} is already declared — one row per (word, context, form)"
131
- end
132
-
133
- path = owner_path(context: context, word: word, opens: opens, paths: paths_for(path))
134
- source = File.read(path)
135
- block = keyword_blocks(source).find { |candidate| candidate.include?(%(context: "#{context}")) } || keyword_block(source)
136
- indent = block[/^(\s*)member /, 1] || " "
137
- row = %(#{indent}member word: "#{word}", context: "#{context}", body: "#{body}", ) +
138
- %(inner: "#{inner}", opens: "#{opens}", fills: "#{fills}", status: "proposed"\n)
139
-
140
- # At the end of the one_of — grouping by context is a courtesy of
141
- # the hand; a proposed row sits at the bottom until admission,
142
- # when whoever admits it may move it home.
143
- closing = block.rindex(/^\s*end\s*$/)
144
- updated = block[0...closing] + row + block[closing..]
145
- File.write(path, source.sub(block, updated))
146
- end
147
-
148
- # Rewrites a declared keyword row's `status:` cell in place.
149
- #
150
- # @param word [String] the keyword's spelling
151
- # @param context [String] the grammar context the word is declared in
152
- # @param to [String] the new status: `"proposed"`, `"admitted"`, `"deprecated"`,
153
- # or `"retired"`
154
- # @param path [String, nil] an explicit single file to search/write, or nil to
155
- # search every syntax path
156
- # @return [void]
157
- # @raise [Refusal] if `to` is not one of the four stations, or `context`.`word`
158
- # is not declared
159
- def set_status(word:, context:, to:, path: nil)
160
- raise Refusal, "#{to.inspect} is not a station a word's life admits" unless %w[proposed admitted deprecated
161
- retired].include?(to)
162
-
163
- path = path_holding_keyword(word, context, paths_for(path))
164
- source = File.read(path)
165
- block = keyword_blocks(source).find { |candidate| candidate.lines.any? { |line| member_row?(line, word, context) } }
166
- rows = block.lines.select { |line| member_row?(line, word, context) }
167
- raise Refusal, "#{context}.#{word} is not declared" if rows.empty?
168
-
169
- updated = block.lines.map do |line|
170
- next line unless member_row?(line, word, context)
171
-
172
- stripped = line.sub(/,\s*status: "[^"]*"/, "")
173
- # Admitted is the default and stays unspelled — only a word
174
- # entering or leaving the language carries its status.
175
- to == "admitted" ? stripped : stripped.sub(/\n\z/, %(, status: "#{to}"\n))
176
- end.join
177
-
178
- File.write(path, source.sub(block, updated))
179
- end
180
-
181
- # A rename respells the row's word and holds the old spelling in
182
- # `was:` — one hop only. Renaming an already-renamed word refuses
183
- # until the language grows real eras for its own words; renaming
184
- # onto a spelling the context already declares refuses too. The
185
- # word's Argument rows follow it — row-aware, not a blind
186
- # substitution (see `cascade_argument_rename`).
187
- #
188
- # @param word [String] the keyword's current spelling
189
- # @param context [String] the grammar context the word is declared in
190
- # @param to [String] the keyword's new spelling
191
- # @param path [String, nil] an explicit single file to search/write, or nil to
192
- # search every syntax path
193
- # @return [void]
194
- # @raise [Refusal] if `context`.`word` is not declared, was already renamed once,
195
- # or `to` is already declared in `context`
196
- def rename(word:, context:, to:, path: nil)
197
- row = keyword_rows(path).find { |r| r[:word] == word && r[:context] == context }
198
- raise Refusal, "#{context}.#{word} is not declared" unless row
199
- raise Refusal, "#{context}.#{word} was already #{row[:was]} — one rename hop, then eras" if row[:was]
200
- if keyword_rows(path).any? do |r|
201
- r[:word] == to && r[:context] == context
202
- end
203
- raise Refusal,
204
- "#{context}.#{to} is already declared — a rename cannot land on a living word"
205
- end
206
-
207
- paths = paths_for(path)
208
- path = path_holding_keyword(word, context, paths)
209
- source = File.read(path)
210
- block = keyword_blocks(source).find { |candidate| candidate.lines.any? { |line| member_row?(line, word, context) } }
211
- updated = block.lines.map do |line|
212
- next line unless member_row?(line, word, context)
213
-
214
- line.sub(%(word: "#{word}"), %(word: "#{to}"))
215
- .sub(/\n\z/, %(, was: "#{word}"\n))
216
- end.join
217
- source = source.sub(block, updated)
218
- File.write(path, source)
219
-
220
- cascade_argument_rename(keyword: word, context: context, to: to, path: paths)
221
- end
222
-
223
- # Tells whether `line` is a KeywordSeed member row for `(word, context)`.
224
- #
225
- # @param line [String] one raw source line
226
- # @param word [String] the keyword's spelling
227
- # @param context [String] the grammar context
228
- # @return [Boolean]
229
- def member_row?(line, word, context)
230
- line =~ /^\s*member / && line.include?(%(word: "#{word}")) && line.include?(%(context: "#{context}"))
231
- end
232
-
233
- # Finds which syntax-table path declares a keyword row.
234
- #
235
- # @param word [String] the keyword's spelling
236
- # @param context [String] the grammar context
237
- # @param paths [Array<String>] candidate syntax-table paths to search
238
- # @return [String] the path whose KeywordSeed declares `(word, context)`
239
- # @raise [Refusal] if no path in `paths` declares that row
240
- def path_holding_keyword(word, context, paths = syntax_paths)
241
- paths.find do |candidate|
242
- keyword_blocks(File.read(candidate)).any? { |block| block.lines.any? { |line| member_row?(line, word, context) } }
243
- end || raise(Refusal, "#{context}.#{word} is not declared")
244
- end
245
-
246
- # Finds which syntax-table path declares an argument row.
247
- #
248
- # @param keyword [String] the argument's owning keyword
249
- # @param context [String] the grammar context
250
- # @param at [String] the argument's positional slot, `""` for a named-only argument
251
- # @param named [String] the argument's keyword name, `""` for a positional-only argument
252
- # @param paths [Array<String>] candidate syntax-table paths to search
253
- # @return [String] the path whose ArgumentSeed declares this row
254
- # @raise [Refusal] if no path in `paths` declares that row
255
- def path_holding_argument(keyword, context, at, named, paths = syntax_paths)
256
- paths.find do |candidate|
257
- argument_blocks(File.read(candidate)).any? do |block|
258
- block.lines.any? do |line|
259
- argument_row?(line, keyword, context, at, named)
260
- end
261
- end
262
- end || raise(Refusal, "#{context}.#{keyword}'s argument at #{at.inspect}/named #{named.inspect} is not declared")
263
- end
264
-
265
- # A new row belongs wherever that context's existing rows live. File is
266
- # intentionally wider than one aggregate; for a new entry point, `opens`
267
- # identifies the aggregate concept whose file should own it.
268
- #
269
- # @param context [String] the grammar context a new row is being added to
270
- # @param word [String] the word being added, used only in the refusal message
271
- # @param opens [String] the aggregate concept a `File`-context word opens
272
- # @param paths [Array<String>] candidate syntax-table paths to search
273
- # @return [String] the path that should own the new row
274
- # @raise [Refusal] if no candidate path declares an existing row for `context`
275
- # (and, for a `File` context with `opens` given, no path declares that aggregate)
276
- def owner_path(context:, word:, opens: "", paths: syntax_paths)
277
- if context == "File" && !opens.to_s.empty?
278
- aggregate_path = paths.find { |candidate| File.read(candidate).match?(/^\s*aggregate "#{Regexp.escape(opens)}" do$/) }
279
- return aggregate_path if aggregate_path
280
- end
281
-
282
- paths.find do |candidate|
283
- source = File.read(candidate)
284
- %w[KeywordSeed ArgumentSeed].any? do |seed|
285
- seed_blocks(source, seed).any? { |block| block.include?(%(context: "#{context}")) }
286
- end
287
- end || raise(Refusal, "no aggregate-local syntax table owns context #{context.inspect} for #{word}")
288
- end
289
-
290
- # Every `value_object "<name>"` block's full source text, from its
291
- # opener to its closing `end`.
292
- #
293
- # @param source [String] a `.bluebook` file's source text
294
- # @param name [String] the value object's name, such as `"KeywordSeed"`
295
- # @return [Array<String>] each matching block's raw source, including the
296
- # opener and closing `end` lines
297
- def seed_blocks(source, name)
298
- opener = /^([ \t]*)value_object "#{Regexp.escape(name)}" do$/
299
- source.to_enum(:scan, opener).map do
300
- match = Regexp.last_match
301
- start = match.begin(0)
302
- indent = match[1]
303
- closing = source.index(/^#{Regexp.escape(indent)}end\s*$/, match.end(0))
304
- closing = source.index(/\n/, closing) + 1
305
- source[start...closing]
306
- end
307
- end
308
-
309
- # The first `value_object "<name>"` block's source text.
310
- #
311
- # @param source [String] a `.bluebook` file's source text
312
- # @param name [String] the value object's name, such as `"KeywordSeed"`
313
- # @param required [Boolean] whether a missing block should raise instead of
314
- # returning nil
315
- # @return [String, nil] the block's raw source, or nil when absent and not
316
- # `required`
317
- # @raise [Refusal] if `required` and `source` declares no such value object
318
- def seed_block(source, name, required: false)
319
- block = seed_blocks(source, name).first
320
- unless block
321
- raise Refusal, "source declares no #{name} value object" if required
322
-
323
- return
324
- end
325
- block
326
- end
327
-
328
- # From `value_object "KeywordSeed"` to its own closing `end` — `member`
329
- # rows sit bare now (S3, ADR 0025 — the `one_of do ... end` wrapper
330
- # is gone), so the first bare `end` line after the opener already
331
- # is the value object's own, the same fact the original one_of-
332
- # nested version of this method leaned on (nothing else nested
333
- # inside it either, before or after).
334
- # @param source [String] a `.bluebook` file's source text
335
- # @return [Array<String>] each KeywordSeed block's raw source
336
- def keyword_blocks(source) = seed_blocks(source, "KeywordSeed")
337
-
338
- # The first KeywordSeed block's source text.
339
- #
340
- # @param source [String] a `.bluebook` file's source text
341
- # @return [String] the first KeywordSeed block's raw source
342
- # @raise [Refusal] if `source` declares no KeywordSeed value object
343
- def keyword_block(source) = seed_block(source, "KeywordSeed", required: true)
344
-
345
- # ── the Argument rows — a word's own arguments, at last with tooling
346
- # of their own rather than the rename-only cascade above. A word may
347
- # carry several argument rows (one per position, one per named
348
- # kwarg), so identity here is the full (keyword, context, at, named)
349
- # tuple, not the two-field key a Keyword row answers to.
350
-
351
- # The ArgumentSeed's member rows, parsed leniently off the text.
352
- #
353
- # @param path [String, nil] an explicit single file to read, or nil for every
354
- # syntax path
355
- # @return [Array<Hash>] one Hash per member row, with `:keyword`, `:context`,
356
- # `:at`, `:named`, `:kind`, `:required`, `:fills`, and `:status`
357
- # (`"admitted"` when unspelled)
358
- # @raise [Refusal] if `path` is given and declares no `ArgumentSeed` value object
359
- def argument_rows(path = nil)
360
- paths_for(path).flat_map do |candidate|
361
- blocks = seed_blocks(File.read(candidate), "ArgumentSeed")
362
- raise Refusal, "source declares no ArgumentSeed value object" if path && blocks.empty?
363
-
364
- blocks.flat_map do |block|
365
- block.scan(/^\s*member (.+)$/).map do |(cells)|
366
- row = cells.scan(/(\w+): "((?:[^"\\]|\\.)*)"/).to_h
367
- { keyword: row["keyword"], context: row["context"], at: row["at"].to_s,
368
- named: row["named"].to_s, kind: row["kind"], required: row["required"],
369
- fills: row["fills"].to_s, status: row.fetch("status", "admitted") }
370
- end
371
- end
372
- end
373
- end
374
-
375
- # `pairs_shape` — for a `pairs` argument that fills one field with a
376
- # whole key/value list rather than naming a field per pair (the
377
- # shape `Handler.dispatch`'s own `with:` already carries). Without
378
- # it, `spec/syntax_conformance_spec.rb` reads a pairs argument
379
- # naming a single field as a row that "names a single field, which
380
- # it cannot fill" — correctly, since the two shapes are genuinely
381
- # different and only one of them can be checked the same way.
382
- # @param keyword [String] the argument's owning keyword
383
- # @param context [String] the grammar context
384
- # @param kind [String] the argument's value kind
385
- # @param required [String] `"true"` or `"false"`, as text like every other cell
386
- # @param at [String] the argument's positional slot, `""` for a named-only argument
387
- # @param named [String] the argument's keyword name, `""` for a positional-only argument
388
- # @param fills [String] the field the argument fills, if any
389
- # @param pairs_shape [String, nil] the shape a `pairs` argument's key/value list
390
- # fills, or nil when this argument is not a `pairs` argument
391
- # @param path [String, nil] an explicit single file to search/write, or nil to
392
- # search every syntax path
393
- # @return [void]
394
- # @raise [Refusal] if this (keyword, context, at, named) row is already declared,
395
- # or no syntax table owns `context`
396
- def propose_argument(keyword:, context:, kind:, required: "false", at: "", named: "", fills: "",
397
- pairs_shape: nil, path: nil)
398
- if argument_rows(path).any? { |r| argument_identity(r) == [keyword, context, at, named] }
399
- raise Refusal, "#{context}.#{keyword}'s argument at #{at.inspect}/named #{named.inspect} is " \
400
- "already declared — one row per (keyword, context, at, named)"
401
- end
402
-
403
- path = owner_path(context: context, word: keyword, paths: paths_for(path))
404
- source = File.read(path)
405
- block = argument_blocks(source).find do |candidate|
406
- candidate.include?(%(context: "#{context}"))
407
- end || argument_block(source)
408
- indent = block[/^(\s*)member /, 1] || " "
409
- shape = pairs_shape.to_s.empty? ? "" : %(pairs_shape: "#{pairs_shape}", )
410
- row = %(#{indent}member keyword: "#{keyword}", context: "#{context}", at: "#{at}", ) +
411
- %(named: "#{named}", kind: "#{kind}", required: "#{required}", fills: "#{fills}", ) +
412
- shape + %(status: "proposed"\n)
413
-
414
- closing = block.rindex(/^\s*end\s*$/)
415
- updated = block[0...closing] + row + block[closing..]
416
- File.write(path, source.sub(block, updated))
417
- end
418
-
419
- # Rewrites a declared argument row's `status:` cell in place.
420
- #
421
- # @param keyword [String] the argument's owning keyword
422
- # @param context [String] the grammar context
423
- # @param to [String] the new status: `"proposed"`, `"admitted"`, `"deprecated"`,
424
- # or `"retired"`
425
- # @param at [String] the argument's positional slot, `""` for a named-only argument
426
- # @param named [String] the argument's keyword name, `""` for a positional-only argument
427
- # @param path [String, nil] an explicit single file to search/write, or nil to
428
- # search every syntax path
429
- # @return [void]
430
- # @raise [Refusal] if `to` is not one of the four stations, or the row is not declared
431
- def set_argument_status(keyword:, context:, to:, at: "", named: "", path: nil)
432
- raise Refusal, "#{to.inspect} is not a station an argument's life admits" unless %w[proposed admitted deprecated
433
- retired].include?(to)
434
-
435
- path = path_holding_argument(keyword, context, at, named, paths_for(path))
436
- source = File.read(path)
437
- block = argument_blocks(source).find do |candidate|
438
- candidate.lines.any? do |line|
439
- argument_row?(line, keyword, context, at, named)
440
- end
441
- end
442
- rows = block.lines.select { |line| argument_row?(line, keyword, context, at, named) }
443
- if rows.empty?
444
- raise Refusal, "#{context}.#{keyword}'s argument at #{at.inspect}/named #{named.inspect} is not " \
445
- "declared"
446
- end
447
-
448
- updated = block.lines.map do |line|
449
- next line unless argument_row?(line, keyword, context, at, named)
450
-
451
- stripped = line.sub(/,\s*status: "[^"]*"/, "")
452
- to == "admitted" ? stripped : stripped.sub(/\n\z/, %(, status: "#{to}"\n))
453
- end.join
454
-
455
- File.write(path, source.sub(block, updated))
456
- end
457
-
458
- # Tells whether `line` is an ArgumentSeed member row for this
459
- # (keyword, context, at, named) tuple.
460
- #
461
- # @param line [String] one raw source line
462
- # @param keyword [String] the argument's owning keyword
463
- # @param context [String] the grammar context
464
- # @param at [String] the argument's positional slot
465
- # @param named [String] the argument's keyword name
466
- # @return [Boolean]
467
- def argument_row?(line, keyword, context, at, named)
468
- line =~ /^\s*member / &&
469
- line.include?(%(keyword: "#{keyword}")) && line.include?(%(context: "#{context}")) &&
470
- line.include?(%(at: "#{at}")) && line.include?(%(named: "#{named}"))
471
- end
472
-
473
- # The identity tuple an argument row is keyed by.
474
- #
475
- # @param row [Hash] an argument row, as `argument_rows` returns one
476
- # @return [Array(String, String, String, String)] the row's `(keyword, context,
477
- # at, named)` identity tuple
478
- def argument_identity(row) = [row[:keyword], row[:context], row[:at], row[:named]]
479
-
480
- # The rename cascade, row-aware — only the rows that actually belong
481
- # to the renamed word, spelling updated in place, rather than a
482
- # blind `gsub` on every `keyword: "word",` substring in the file
483
- # (which a coincidentally-matching row elsewhere could have
484
- # corrupted, and which read nothing before writing).
485
- # @param keyword [String] the keyword whose argument rows follow its rename
486
- # @param context [String] the grammar context
487
- # @param to [String] the keyword's new spelling
488
- # @param path [String, nil] an explicit single file to search/write, or nil to
489
- # search every syntax path
490
- # @return [void]
491
- def cascade_argument_rename(keyword:, context:, to:, path: nil)
492
- paths_for(path).each do |candidate|
493
- source = File.read(candidate)
494
- original = source
495
- argument_blocks(source).each do |block|
496
- updated = block.lines.map do |line|
497
- next line unless line =~ /^\s*member / &&
498
- line.include?(%(keyword: "#{keyword}")) && line.include?(%(context: "#{context}"))
499
-
500
- line.sub(%(keyword: "#{keyword}"), %(keyword: "#{to}"))
501
- end.join
502
- source = source.sub(block, updated) if updated != block
503
- end
504
- File.write(candidate, source) if source != original
505
- end
506
- end
507
-
508
- # From `value_object "ArgumentSeed"` to its own closing `end` — see
509
- # `keyword_block`'s own comment for why the first bare `end` after
510
- # the opener is already the right one, now that `member` rows sit
511
- # bare (S3, ADR 0025).
512
- # @param source [String] a `.bluebook` file's source text
513
- # @return [Array<String>] each ArgumentSeed block's raw source
514
- def argument_blocks(source) = seed_blocks(source, "ArgumentSeed")
515
-
516
- # The first ArgumentSeed block's source text.
517
- #
518
- # @param source [String] a `.bluebook` file's source text
519
- # @return [String] the first ArgumentSeed block's raw source
520
- # @raise [Refusal] if `source` declares no ArgumentSeed value object
521
- def argument_block(source) = seed_block(source, "ArgumentSeed", required: true)
522
- end
523
- end
524
- end