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 +4 -4
- data/CHANGELOG.md +60 -0
- data/lib/tina4/cli.rb +280 -13
- data/lib/tina4/import_helper.rb +185 -0
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4.rb +6 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37edf07ae6df58c20f28ceae195925afd87d2cb2386264725aeadfc642606de0
|
|
4
|
+
data.tar.gz: 45a45a99a2147c18ac8d6eb40d46148e87ce759b14971d6137ac39c902dd04dc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
{
|
|
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
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.
|
|
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-
|
|
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
|