tina4ruby 3.13.116 → 3.13.117

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f87f8715c8f265ae840f6f060d9d31be518c45120cd83881be55c9bfa54ddee
4
- data.tar.gz: 1435132552d0d98a2feecd3da65e1674cc5dce8753f56661a20c2d955c7187c0
3
+ metadata.gz: 37edf07ae6df58c20f28ceae195925afd87d2cb2386264725aeadfc642606de0
4
+ data.tar.gz: 45a45a99a2147c18ac8d6eb40d46148e87ce759b14971d6137ac39c902dd04dc
5
5
  SHA512:
6
- metadata.gz: a3dab7c63858f731fb06d8cd35a01497da574a528f32f602c2234a4ca43cfcd29612ed500a60d303d21f64bbaa3fa58e27622d921fb40cd4ca564ceccd8003d1
7
- data.tar.gz: 40cc64acd13b62b74472aea5fcfe6557533ab61d1e6ba4d9c49d332882b388717966f54a475a0cc24e6d333def06da1f02abaa39b99ff0396ed0da180fbfd806
6
+ metadata.gz: 2b2c4307bd83d533ae01e29108170853168bf1829275a527c036c81d98dd06930a0db8d39291bb0b257c8902d25f38c8593892666392ab9073d94e868ccdfb93
7
+ data.tar.gz: 3f06a521e5904739120c5721b2419601308a5295dc53dba5180e6d24853c2f7f0fe33fbd29dc368eefc60fc815ac8f5d7a178d722b29bd8b22bdcc200b464522
data/CHANGELOG.md CHANGED
@@ -6,6 +6,66 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.117
10
+
11
+ Agent-experience release. Two paired features (import-hint fallback +
12
+ generate-resolution transparency) attack the same defect class: the
13
+ framework silently transforming input, then failing downstream with a
14
+ message that never names the transformation. See ADR-0062.
15
+
16
+ ### Import-hint fallback (Tina4::Routr -> Tina4::Router)
17
+
18
+ - New `lib/tina4/import_helper.rb`: two hooks in Ruby's namespace.
19
+ `Tina4.const_missing(name)` catches `Tina4::<Anything>` lookups
20
+ that Ruby could not resolve, walks `Tina4.constants` recursively
21
+ with `DidYouMean::SpellChecker` for the close match, raises
22
+ `NameError` with the suggestion. A `Kernel#require` wrap catches
23
+ `LoadError` for paths prefixed `tina4/`, walks `lib/tina4/*.rb`
24
+ for close matches, re-raises with the suggestion.
25
+ - Bounded to the `Tina4::*` and `tina4/*` surfaces. Everything else
26
+ passes through untouched.
27
+ - Installed from `lib/tina4.rb` at the end of the require chain.
28
+ Idempotent.
29
+ - 8 real-subprocess spec examples cover positive-happy, negative-hint
30
+ (const + require), negative-no-match, masking-gate, and a live
31
+ mutation-gate example that stashes/restores the helper to prove
32
+ the hint text is genuinely dependent on the finder.
33
+
34
+ ### Generate-command resolution transparency
35
+
36
+ - `tina4ruby generate model|route|migration|middleware --json` emits
37
+ a versioned envelope on stdout, matching the `generate_v1`
38
+ contract advertised in `commands --json`.
39
+ - `--dry-run` computes resolution WITHOUT writing files. Composable
40
+ with `--json`.
41
+ - Bare invocation prints a human-readable resolution block to stderr
42
+ naming every transformation, path and warning; files are written
43
+ as before.
44
+ - Introduced `SQL_RESERVED_TABLE_NAMES` in `lib/tina4/cli.rb` mirroring
45
+ the Python master. `generate model Order` now surfaces the
46
+ "auto-pluralized" transformation and names the `--table X --quote`
47
+ override flag (parsed; the quoted-identifier ORM mode it opts into
48
+ is tracked at tina4-python#123 for a follow-up).
49
+ - `commands --json` gains `"resolution_contract" => {"version" => "1",
50
+ "envelope" => "generate_v1"}`.
51
+ - 6 real-subprocess spec examples covering json+dry_run,
52
+ reserved-word transformation, bare stderr, reserved-name stderr,
53
+ manifest advertisement.
54
+
55
+ Side-fixes surfaced by the new reserved-word policy:
56
+
57
+ - `generate crud/form/view` had a pre-existing `"#{table}s"` bug
58
+ that only appeared once reserved-word tables started pluralising
59
+ (Order -> orders -> orderss). Introduced `to_route_name` that
60
+ always pluralises from the class-name snake. `Category` was
61
+ ALREADY producing `categorys` (wrong); now correctly `categories`.
62
+ - Detail-view path collision (`#{table}.twig` collided with the
63
+ list view for reserved-word classes; both `orders.twig`). Fixed
64
+ by keying detail on `to_snake_case(name)`.
65
+
66
+ Parity: tina4-python, tina4-php, tina4-nodejs ship the same two
67
+ features in 3.13.117 through their language-native mechanisms.
68
+
9
69
  ## 3.13.116
10
70
 
11
71
  Cooperative service-runner shutdown + version-contract test hardening.
data/lib/tina4/cli.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "optparse"
4
4
  require "fileutils"
5
+ require "set"
6
+ require "json"
5
7
  require_relative "port_takeover"
6
8
 
7
9
  module Tina4
@@ -228,9 +230,54 @@ module Tina4
228
230
  .downcase
229
231
  end
230
232
 
231
- # Class name -> singular table name: Product -> product
233
+ # Table names that collide with SQL reserved words. `CREATE TABLE order (...)`
234
+ # is a syntax error on every engine, and the ORM interpolates table names
235
+ # into SQL unquoted (and hands the raw name to driver insert/update/delete),
236
+ # so the safe fix is to never GENERATE one. The plural form is not reserved
237
+ # and reads naturally as a table name. Mirrored from the Python master
238
+ # (tina4-python/tina4_python/cli/__init__.py:62-75) so a `generate model`
239
+ # in any framework picks the same table name for the same class.
240
+ SQL_RESERVED_TABLE_NAMES = %w[
241
+ order group user table select from where index
242
+ key values column constraint check default primary
243
+ foreign references unique join union having limit
244
+ offset desc asc case when then else end and
245
+ or not null insert update delete create drop
246
+ alter grant revoke commit rollback view trigger
247
+ procedure function database schema session set into
248
+ as on by inner outer left right full natural
249
+ using with distinct between exists like in is
250
+ all any cross add row rows range current to
251
+ ].to_set.freeze
252
+
253
+ # Simple English plural, used to escape a reserved table name.
254
+ # Mirrors `_pluralize_table` in tina4-python/tina4_python/cli/__init__.py:76.
255
+ def pluralize_table(name)
256
+ return "#{name[0..-2]}ies" if name.end_with?("y") &&
257
+ !name.end_with?("ay", "ey", "iy", "oy", "uy")
258
+ return "#{name}es" if name.end_with?("s", "x", "z", "ch", "sh")
259
+
260
+ "#{name}s"
261
+ end
262
+
263
+ # Class name -> singular table name: Product -> product.
264
+ # A name colliding with a SQL reserved word is pluralised instead
265
+ # (Order -> orders). Every generator routes through here, so the model's
266
+ # `table_name`, the migration DDL, the routes and the tests all agree.
232
267
  def to_table_name(name)
233
- to_snake_case(name)
268
+ table = to_snake_case(name)
269
+ SQL_RESERVED_TABLE_NAMES.include?(table) ? pluralize_table(table) : table
270
+ end
271
+
272
+ # Class name -> plural route/resource segment: Product -> products,
273
+ # Order -> orders, Category -> categories. Used by generate_crud /
274
+ # generate_form / generate_view for the URL path and the plural view
275
+ # filename. Naive `"#{table}s"` doubled the `s` when `table` was ALREADY
276
+ # plural from the reserved-word pluralize (Order -> orders -> orderss)
277
+ # and produced the wrong plural for `y`-ending / sibilant-ending names
278
+ # (Category -> categorys). This routes both through `pluralize_table`.
279
+ def to_route_name(name)
280
+ pluralize_table(to_snake_case(name))
234
281
  end
235
282
 
236
283
  # Called without --fields, the generators fall back to a single `name`
@@ -328,7 +375,7 @@ module Tina4
328
375
  # Parse --key value and --flag from args. Returns [flags_hash, positional_array]
329
376
  def parse_flags(args)
330
377
  # Boolean-only flags that never take a value argument
331
- boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration once]
378
+ boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration once dry-run]
332
379
 
333
380
  flags = {}
334
381
  positional = []
@@ -1060,6 +1107,14 @@ module Tina4
1060
1107
  end
1061
1108
 
1062
1109
  # ── generate ────────────────────────────────────────────────────────
1110
+ #
1111
+ # The generate command has two OUTPUT modes: human (default) and JSON
1112
+ # (`--json`). A `--dry-run` short-circuits the file writes but still
1113
+ # emits the resolution so a caller can PLAN a scaffold without touching
1114
+ # the working tree. This is `resolution_contract.envelope = generate_v1`
1115
+ # (see `commands_manifest`) — one machine-readable answer, per target,
1116
+ # for AI agents that need to know exactly what a `generate model Order`
1117
+ # will produce before it runs.
1063
1118
 
1064
1119
  def cmd_generate(argv)
1065
1120
  what = argv.shift
@@ -1071,6 +1126,8 @@ module Tina4
1071
1126
  puts ' Options: --fields "name:string,price:float" --model ModelName'
1072
1127
  puts ' --public open a route'"'"'s writes (default: secure)'
1073
1128
  puts ' --every 5m | --cron "..." service schedule'
1129
+ puts ' --json emit the generate_v1 resolution envelope'
1130
+ puts ' --dry-run plan the scaffold; write no files'
1074
1131
  exit 1
1075
1132
  end
1076
1133
 
@@ -1086,16 +1143,204 @@ module Tina4
1086
1143
  name = no_name_generators.include?(what) ? "" : argv.shift
1087
1144
  flags, _positional = parse_flags(argv)
1088
1145
 
1146
+ json_mode = flags.delete("json") ? true : false
1147
+ dry_run = flags.delete("dry-run") ? true : false
1148
+
1089
1149
  # Dispatch from the GENERATORS registry (single source of truth for the
1090
1150
  # generate subcommands; also feeds #cmd_help and the manifest).
1091
1151
  gen_spec = GENERATORS[what]
1092
- if gen_spec
1093
- send(gen_spec[:handler], name, flags)
1094
- else
1152
+ unless gen_spec
1095
1153
  puts "Unknown generator: #{what}"
1096
1154
  puts " Available: #{all}"
1097
1155
  exit 1
1098
1156
  end
1157
+
1158
+ # Only the four resolution-aware targets emit a resolution + JSON envelope;
1159
+ # every other generator keeps its existing behaviour so nothing regresses.
1160
+ resolution_aware = %w[model route migration middleware]
1161
+ if !resolution_aware.include?(what)
1162
+ send(gen_spec[:handler], name, flags)
1163
+ return
1164
+ end
1165
+
1166
+ resolution = build_generate_resolution(what, name, flags)
1167
+
1168
+ if json_mode
1169
+ actions_taken = dry_run ? [] : run_generator_capturing_actions(gen_spec, name, flags)
1170
+ envelope = build_generate_envelope(what, name, flags, resolution, actions_taken, dry_run)
1171
+ puts JSON.pretty_generate(envelope)
1172
+ return
1173
+ end
1174
+
1175
+ # Human mode: print the resolution block to STDERR BEFORE writing files,
1176
+ # so a caller can Ctrl-C between the plan and the write.
1177
+ $stderr.puts format_generate_resolution_block(what, name, resolution)
1178
+
1179
+ if dry_run
1180
+ $stderr.puts " (dry-run — no files written)"
1181
+ else
1182
+ send(gen_spec[:handler], name, flags)
1183
+ end
1184
+ end
1185
+
1186
+ # ── generate resolution helpers ────────────────────────────────────────
1187
+
1188
+ # One `resolution` per target, with the SAME KEY SET so a caller can
1189
+ # rely on the envelope shape regardless of what they generated:
1190
+ #
1191
+ # class_name, table_name, file_path, migration_path,
1192
+ # transformations, routes, test_paths
1193
+ #
1194
+ # Fields that don't apply to a target (a middleware has no table_name;
1195
+ # a migration is defined by its migration_path) are `nil` rather than
1196
+ # missing — a stable envelope beats a compact one for machine callers.
1197
+ def build_generate_resolution(target, name, flags)
1198
+ case target
1199
+ when "model" then build_model_resolution(name, flags)
1200
+ when "route" then build_route_resolution(name, flags)
1201
+ when "migration" then build_migration_resolution(name, flags)
1202
+ when "middleware" then build_middleware_resolution(name, flags)
1203
+ else
1204
+ { "class_name" => name.to_s, "table_name" => nil, "file_path" => nil,
1205
+ "migration_path" => nil, "transformations" => [],
1206
+ "routes" => [], "test_paths" => [] }
1207
+ end
1208
+ end
1209
+
1210
+ def build_model_resolution(name, flags)
1211
+ raw = to_snake_case(name)
1212
+ transformations = []
1213
+ table =
1214
+ if SQL_RESERVED_TABLE_NAMES.include?(raw)
1215
+ plural = pluralize_table(raw)
1216
+ transformations << {
1217
+ "kind" => "reserved_word_pluralize",
1218
+ "from" => raw,
1219
+ "to" => plural,
1220
+ "reason" => "SQL reserved word '#{raw}' would break CREATE TABLE",
1221
+ "override" => "--table #{raw} --quote (requires quoted-identifier mode, not yet implemented)"
1222
+ }
1223
+ plural
1224
+ else
1225
+ raw
1226
+ end
1227
+ # The migration filename embeds a timestamp; predict it deterministically
1228
+ # so a `--dry-run` and the real run agree on the same second. Two
1229
+ # generations within the same second collide anyway (existing behaviour).
1230
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
1231
+ {
1232
+ "class_name" => name.to_s,
1233
+ "table_name" => table,
1234
+ "file_path" => "src/orm/#{raw}.rb",
1235
+ "migration_path" => "migrations/#{timestamp}_create_#{table}.sql",
1236
+ "transformations" => transformations,
1237
+ "routes" => ["/#{table}", "/#{table}/{id}"],
1238
+ "test_paths" => ["spec/#{raw}_spec.rb"]
1239
+ }
1240
+ end
1241
+
1242
+ def build_route_resolution(name, flags)
1243
+ route_path = name.to_s.sub(%r{^/}, "")
1244
+ {
1245
+ "class_name" => nil,
1246
+ "table_name" => nil,
1247
+ "file_path" => "src/routes/#{route_path}.rb",
1248
+ "migration_path" => nil,
1249
+ "transformations" => [],
1250
+ "routes" => ["/api/#{route_path}", "/api/#{route_path}/{id}"],
1251
+ "test_paths" => ["spec/routes/#{route_path}_spec.rb"]
1252
+ }
1253
+ end
1254
+
1255
+ def build_migration_resolution(name, _flags)
1256
+ timestamp = Time.now.strftime("%Y%m%d%H%M%S")
1257
+ base = name.to_s.sub(/^create_/, "").sub(/^add_/, "").sub(/^drop_/, "")
1258
+ snake_base = to_snake_case(base)
1259
+ path = "migrations/#{timestamp}_#{name}.sql"
1260
+ {
1261
+ "class_name" => nil,
1262
+ "table_name" => snake_base,
1263
+ "file_path" => path,
1264
+ "migration_path" => path,
1265
+ "transformations" => [],
1266
+ "routes" => [],
1267
+ "test_paths" => []
1268
+ }
1269
+ end
1270
+
1271
+ def build_middleware_resolution(name, _flags)
1272
+ snake = to_snake_case(name)
1273
+ {
1274
+ "class_name" => name.to_s,
1275
+ "table_name" => nil,
1276
+ "file_path" => "src/middleware/#{snake}.rb",
1277
+ "migration_path" => nil,
1278
+ "transformations" => [],
1279
+ "routes" => [],
1280
+ "test_paths" => []
1281
+ }
1282
+ end
1283
+
1284
+ def build_generate_envelope(target, name, flags, resolution, actions_taken, dry_run)
1285
+ {
1286
+ "command" => "generate",
1287
+ "target" => target,
1288
+ "input" => { "name" => name.to_s, "fields" => flags["fields"] },
1289
+ "resolution" => resolution,
1290
+ "actions_taken" => actions_taken,
1291
+ "dry_run" => dry_run
1292
+ }
1293
+ end
1294
+
1295
+ # Human-readable resolution block emitted to STDERR before a bare
1296
+ # `generate model Order` writes files. Only shows the "keep raw name"
1297
+ # hint when a reserved-word pluralize actually fired.
1298
+ def format_generate_resolution_block(target, name, resolution)
1299
+ lines = ["Generated #{target} #{name}"]
1300
+ if resolution["class_name"]
1301
+ lines << " class #{resolution['class_name']} (in #{resolution['file_path']})"
1302
+ elsif resolution["file_path"]
1303
+ lines << " file #{resolution['file_path']}"
1304
+ end
1305
+ if resolution["table_name"]
1306
+ pluralized = resolution["transformations"].find { |t| t["kind"] == "reserved_word_pluralize" }
1307
+ note = pluralized ? " (auto-pluralized: '#{pluralized['from']}' is a SQL reserved word)" : ""
1308
+ lines << " table #{resolution['table_name']}#{note}"
1309
+ end
1310
+ if resolution["routes"] && !resolution["routes"].empty?
1311
+ lines << " routes #{resolution['routes'].join(', ')}"
1312
+ end
1313
+ if resolution["migration_path"] && resolution["migration_path"] != resolution["file_path"]
1314
+ lines << " migration #{resolution['migration_path']}"
1315
+ end
1316
+ pluralized = resolution["transformations"].find { |t| t["kind"] == "reserved_word_pluralize" }
1317
+ if pluralized
1318
+ lines << ""
1319
+ lines << " To keep the raw name '#{pluralized['from']}' as the table:"
1320
+ lines << " tina4ruby generate #{target} #{name} --table #{pluralized['from']} --quote (opt-in, ADR-0062 forthcoming)"
1321
+ end
1322
+ lines.join("\n")
1323
+ end
1324
+
1325
+ # Run a generator with its usual stdout output CAPTURED, and derive an
1326
+ # `actions_taken` list from the " Created <path>" lines the generators
1327
+ # emit. Machine callers get one clean JSON envelope on stdout; the
1328
+ # generator's own log stays discoverable via the returned list.
1329
+ def run_generator_capturing_actions(gen_spec, name, flags)
1330
+ require "stringio"
1331
+ require "json"
1332
+ buffer = StringIO.new
1333
+ original = $stdout
1334
+ $stdout = buffer
1335
+ begin
1336
+ send(gen_spec[:handler], name, flags)
1337
+ ensure
1338
+ $stdout = original
1339
+ end
1340
+ buffer.string.each_line
1341
+ .map(&:strip)
1342
+ .select { |line| line.start_with?("Created ") }
1343
+ .map { |line| "wrote #{line.sub(/^Created\s+/, '')}" }
1099
1344
  end
1100
1345
 
1101
1346
  # ── Generator: model ─────────────────────────────────────────────────
@@ -1348,7 +1593,11 @@ module Tina4
1348
1593
 
1349
1594
  def generate_crud(name, flags)
1350
1595
  table = to_table_name(name)
1351
- route_name = "#{table}s"
1596
+ # Always derive the plural route from the CLASS NAME, not by appending
1597
+ # "s" to the table — otherwise a reserved-word table (already plural,
1598
+ # Order -> orders) becomes orderss, and a `y`-ending class (Category)
1599
+ # becomes categorys instead of categories.
1600
+ route_name = to_route_name(name)
1352
1601
  is_public = flags["public"] ? true : false
1353
1602
 
1354
1603
  puts "\n Generating CRUD for #{name}...\n"
@@ -1649,7 +1898,7 @@ module Tina4
1649
1898
  def generate_form(name, flags = {})
1650
1899
  fields = fields_or_default(flags["fields"])
1651
1900
  table = to_table_name(name)
1652
- route_name = "#{table}s"
1901
+ route_name = to_route_name(name)
1653
1902
 
1654
1903
  # Input type mapping
1655
1904
  input_types = {
@@ -1662,7 +1911,11 @@ module Tina4
1662
1911
 
1663
1912
  dir = "src/templates/forms"
1664
1913
  FileUtils.mkdir_p(dir)
1665
- path = File.join(dir, "#{table}.twig")
1914
+ # Form template is for creating/editing ONE record, so the filename is
1915
+ # keyed by the singular class snake (order.twig), never by the plural
1916
+ # table (which for a reserved-word class Order would be orders.twig
1917
+ # and read wrong for a form).
1918
+ path = File.join(dir, "#{to_snake_case(name)}.twig")
1666
1919
  if File.exist?(path)
1667
1920
  puts " File already exists: #{path}"
1668
1921
  return
@@ -1727,7 +1980,7 @@ module Tina4
1727
1980
  def generate_view(name, flags = {})
1728
1981
  fields = fields_or_default(flags["fields"])
1729
1982
  table = to_table_name(name)
1730
- route_name = "#{table}s"
1983
+ route_name = to_route_name(name)
1731
1984
 
1732
1985
  cols = fields.map { |f, _| f }
1733
1986
 
@@ -1778,8 +2031,13 @@ module Tina4
1778
2031
  puts " Created #{list_path}"
1779
2032
  end
1780
2033
 
1781
- # Detail view
1782
- detail_path = File.join(dir, "#{table}.twig")
2034
+ # Detail view — file named by the singular CLASS NAME (snake_case), not
2035
+ # the table name. When the class is a SQL reserved word the table has
2036
+ # been pluralised (Order -> orders), and reusing that name would
2037
+ # collide with the list view path just above. The class-name snake
2038
+ # (order.twig) is the natural detail-page filename and matches the
2039
+ # pre-reserved-word behaviour for every non-reserved class.
2040
+ detail_path = File.join(dir, "#{to_snake_case(name)}.twig")
1783
2041
  unless File.exist?(detail_path)
1784
2042
  detail_fields = cols.map do |c|
1785
2043
  " <div class=\"mb-3\"><strong>#{c.tr('_', ' ').split.map(&:capitalize).join(' ')}:</strong> {{ item.#{c} }}</div>"
@@ -2880,7 +3138,16 @@ module Tina4
2880
3138
  entry["args"] = spec[:args].dup if spec[:args]
2881
3139
  entry
2882
3140
  end
2883
- { "framework" => "ruby", "version" => Tina4::VERSION, "commands" => commands }
3141
+ {
3142
+ "framework" => "ruby",
3143
+ "version" => Tina4::VERSION,
3144
+ "commands" => commands,
3145
+ # Contract for the `generate --json` envelope so a machine caller can
3146
+ # KNOW the envelope shape before it invokes generate (see cmd_generate).
3147
+ # Bump `version` when the envelope shape changes; add a new envelope
3148
+ # name rather than mutating the existing one.
3149
+ "resolution_contract" => { "version" => "1", "envelope" => "generate_v1" }
3150
+ }
2884
3151
  end
2885
3152
 
2886
3153
  # Emit the CLI's own command surface — the self-describing manifest.
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/tina4/import_helper.rb
4
+ #
5
+ # AI-agent experience: fail LOUD with an actionable hint when a wrong Tina4
6
+ # import is guessed. Two bounded hooks, installed once at framework boot from
7
+ # `lib/tina4.rb`:
8
+ #
9
+ # 1. `Tina4.const_missing(:Route)` -> "uninitialized constant Tina4::Route.
10
+ # Did you mean Tina4::Router?" (using stdlib DidYouMean; Levenshtein
11
+ # fallback if unavailable).
12
+ #
13
+ # 2. `require "tina4/route"` (unknown framework file) -> "cannot load such
14
+ # file -- tina4/route. Did you mean tina4/router?" — walks the real files
15
+ # that ship in `lib/tina4/*.rb` for close matches.
16
+ #
17
+ # BOTH hooks are BOUNDED: the constant hook only fires for `Tina4::*`, and the
18
+ # require hook only intervenes when the requested path starts with `tina4/`
19
+ # AND the resulting LoadError is about that exact path. Anything else passes
20
+ # through unchanged — a genuinely missing gem must surface as its own real
21
+ # error, not be masked by our hint (`spec/import_helper_spec.rb` locks that in
22
+ # with the "broken_module" masking gate).
23
+ #
24
+ # The hint is a suggestion, never a truth claim: the raised class stays
25
+ # NameError / LoadError, so callers who rescue those still work.
26
+
27
+ module Tina4
28
+ module ImportHelper
29
+ # The directory that holds the real framework files (lib/tina4/*.rb).
30
+ # Computed from this file's own location so it survives being loaded from
31
+ # a gem install path, a git checkout, or a worktree alike.
32
+ FRAMEWORK_DIR = File.expand_path(__dir__).freeze
33
+
34
+ class << self
35
+ # Install both hooks. Idempotent: safe to call more than once (extra
36
+ # calls no-op). The framework boot in `lib/tina4.rb` calls this exactly
37
+ # once at the end of its own require chain.
38
+ def install
39
+ return if @installed
40
+
41
+ # Load DidYouMean once, quietly. It ships with Ruby 3.1+ (the
42
+ # framework's minimum), but never *fail* boot if it happens to be
43
+ # absent: the close_matches Levenshtein fallback covers that case.
44
+ begin
45
+ require "did_you_mean"
46
+ rescue LoadError
47
+ # fall through to the Levenshtein path in close_matches
48
+ end
49
+
50
+ Tina4.singleton_class.prepend(ConstMissing)
51
+ Kernel.prepend(RequireHook)
52
+ @installed = true
53
+ end
54
+
55
+ def installed?
56
+ @installed == true
57
+ end
58
+
59
+ # Return up to `max` close-match strings from `dictionary` for `word`.
60
+ # Uses stdlib DidYouMean::SpellChecker where available (Ruby 3.1+ ships
61
+ # it), and falls back to a small Levenshtein implementation otherwise.
62
+ #
63
+ # Never raises: an empty dictionary or a blank word returns [].
64
+ def close_matches(word, dictionary, max: 3)
65
+ list = Array(dictionary).map(&:to_s)
66
+ return [] if list.empty? || word.to_s.empty?
67
+
68
+ if defined?(DidYouMean::SpellChecker)
69
+ suggestions = DidYouMean::SpellChecker.new(dictionary: list).correct(word.to_s)
70
+ return suggestions.first(max) unless suggestions.nil? || suggestions.empty?
71
+ end
72
+
73
+ # Levenshtein fallback: rank by edit distance, keep the closest few
74
+ # within a reasonable threshold so a truly wrong guess still returns [].
75
+ scored = list.map { |candidate| [candidate, levenshtein(word.to_s, candidate)] }
76
+ threshold = [(word.to_s.length / 3.0).ceil, 3].max
77
+ scored.select { |_, distance| distance <= threshold }
78
+ .sort_by { |candidate, distance| [distance, candidate] }
79
+ .first(max)
80
+ .map(&:first)
81
+ end
82
+
83
+ # A short, ordered sample of real Tina4 constants — used when nothing
84
+ # was close enough to suggest, so the reader still leaves with something
85
+ # to try.
86
+ def some_tina4_constants(limit = 5)
87
+ Tina4.constants.map(&:to_s).sort.first(limit)
88
+ end
89
+
90
+ private
91
+
92
+ # Iterative two-row Levenshtein: O(a.length * b.length) time,
93
+ # O(b.length) space. Pure logic, no allocations of intermediate strings.
94
+ def levenshtein(a, b)
95
+ return b.length if a.empty?
96
+ return a.length if b.empty?
97
+
98
+ prev = (0..b.length).to_a
99
+ (1..a.length).each do |i|
100
+ curr = Array.new(b.length + 1, 0)
101
+ curr[0] = i
102
+ (1..b.length).each do |j|
103
+ cost = a[i - 1] == b[j - 1] ? 0 : 1
104
+ curr[j] = [
105
+ curr[j - 1] + 1,
106
+ prev[j] + 1,
107
+ prev[j - 1] + cost
108
+ ].min
109
+ end
110
+ prev = curr
111
+ end
112
+ prev[b.length]
113
+ end
114
+ end
115
+
116
+ # Hook 1 -- Tina4::<Missing>. Only fires when a bare `Tina4::<Name>`
117
+ # constant is asked for and does not exist. Ruby dispatches
118
+ # `const_missing` on the module itself (via its singleton class), so a
119
+ # prepended `ConstMissing#const_missing` runs first and re-raises with a
120
+ # helpful hint.
121
+ module ConstMissing
122
+ def const_missing(name)
123
+ real = Tina4.constants.map(&:to_s)
124
+ matches = Tina4::ImportHelper.close_matches(name.to_s, real)
125
+
126
+ message =
127
+ if matches.any?
128
+ "uninitialized constant Tina4::#{name}. Did you mean Tina4::#{matches.first}?"
129
+ else
130
+ examples = Tina4::ImportHelper.some_tina4_constants
131
+ hint =
132
+ if examples.any?
133
+ " Real Tina4 constants include: #{examples.map { |c| "Tina4::#{c}" }.join(', ')}."
134
+ else
135
+ ""
136
+ end
137
+ "uninitialized constant Tina4::#{name}.#{hint}"
138
+ end
139
+
140
+ # NameError.new(msg, name) records the missing constant symbol, so
141
+ # code doing `rescue NameError => e; e.name` still works.
142
+ raise NameError.new(message, name)
143
+ end
144
+ end
145
+
146
+ # Hook 2 -- require "tina4/<something>". Runs BEFORE the real
147
+ # Kernel#require via `Module#prepend`. On success, indistinguishable from
148
+ # plain require. On failure, only rewrites the LoadError when:
149
+ #
150
+ # * the requested path starts with "tina4/", AND
151
+ # * the LoadError's own #path is nil or equal to that requested path.
152
+ #
153
+ # The second condition is the masking gate: a nested `require
154
+ # "definitely_missing_gem"` inside a loaded tina4/*.rb raises a LoadError
155
+ # whose #path is that inner name — we MUST propagate it unchanged, not
156
+ # replace it with a spurious "did you mean tina4/router?" hint.
157
+ module RequireHook
158
+ def require(path)
159
+ super
160
+ rescue LoadError => e
161
+ raise unless path.is_a?(String) && path.start_with?("tina4/")
162
+
163
+ offending = e.respond_to?(:path) ? e.path : nil
164
+ raise unless offending.nil? || offending == path
165
+
166
+ base = path.sub(%r{\Atina4/}, "")
167
+ real = Dir.glob(File.join(Tina4::ImportHelper::FRAMEWORK_DIR, "*.rb"))
168
+ .map { |file| File.basename(file, ".rb") }
169
+ matches = Tina4::ImportHelper.close_matches(base, real)
170
+
171
+ if matches.any?
172
+ suggestion = matches.first
173
+ raise LoadError, "cannot load such file -- #{path}. Did you mean tina4/#{suggestion}?"
174
+ else
175
+ raise LoadError, "cannot load such file -- #{path}. No close match under tina4/*."
176
+ end
177
+ end
178
+
179
+ # Preserve Kernel#require's private visibility: prepending a module that
180
+ # publicly defines `require` would otherwise leak `some_obj.require(...)`
181
+ # as a public method. Keep the surface identical.
182
+ private :require
183
+ end
184
+ end
185
+ end
data/lib/tina4/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.116"
4
+ VERSION = "3.13.117"
5
5
  end
data/lib/tina4.rb CHANGED
@@ -59,6 +59,12 @@ require_relative "tina4/context"
59
59
  require_relative "tina4/mcp"
60
60
  require_relative "tina4/realtime"
61
61
 
62
+ # AI-agent experience: install the constant-lookup and require-path hint hooks
63
+ # LAST, once every real Tina4::* constant and every lib/tina4/*.rb file is
64
+ # discoverable. Idempotent — safe to load lib/tina4 more than once.
65
+ require_relative "tina4/import_helper"
66
+ Tina4::ImportHelper.install
67
+
62
68
  module Tina4
63
69
  # Bind address, with the framework name winning over the bare one.
64
70
  #
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.116
4
+ version: 3.13.117
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-24 00:00:00.000000000 Z
11
+ date: 2026-08-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -389,6 +389,7 @@ files:
389
389
  - lib/tina4/graphql.rb
390
390
  - lib/tina4/health.rb
391
391
  - lib/tina4/html_element.rb
392
+ - lib/tina4/import_helper.rb
392
393
  - lib/tina4/job.rb
393
394
  - lib/tina4/localization.rb
394
395
  - lib/tina4/log.rb