tina4ruby 3.13.127 → 3.13.129

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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/lib/tina4/cli.rb +196 -23
  3. data/lib/tina4/version.rb +1 -1
  4. metadata +2 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0765c0228c99d3b8d5e26124c0afe5079ccc9ac675a3f067fc38e2e829bded7f
4
- data.tar.gz: 6134c34d7f1ea98ea558862e1f98dbb0532b2d6fb3156b8bad5b9e129e362475
3
+ metadata.gz: e0792f247e0fc7d10a52db84896d8cd475d509e258a1b121b06564e45cfa3913
4
+ data.tar.gz: 91ecd21265adabfd4874596c68b63f10c36bab1cb39fd49c4be4b0db1d8ec289
5
5
  SHA512:
6
- metadata.gz: '0979843fa3654d0c48c62c19bbc8e5615a7b4b582286e14b3c517b76f3d9a50eb3246feb125b365b071c2c76adf7cdcbf93f802a576d75e5959d337cd19543c0'
7
- data.tar.gz: f5ba6720f96dee12b2b7d751282fba8835771a11b368afafd64bf311c9ca1d8bf8501fd3b28493dd42126dfb0b4159a4c50a407422d5b8f63b680b7b67dbe038
6
+ metadata.gz: a464c761ff621eeb3a10068a1ad436fe1b71d1403792885a07442b1f8dc475d80c1f64736e3a456c2a71edd72c0fee6fabd215e8fb61e6eb543e2d2b02531831
7
+ data.tar.gz: 86c6e9cb683134790b85159c797225c3bb8d126fe8fbc1d0bfc8d0f3aeaf1dfcf781b5a21ae4d8cf414c04875726a32db98cc5cfd12b87b10603e815d805e17a
data/lib/tina4/cli.rb CHANGED
@@ -6,6 +6,7 @@ require "set"
6
6
  require "json"
7
7
  require "tmpdir" # Dir.mktmpdir — used by the ADR-0063 sandbox edit-hint scan
8
8
  require "stringio" # StringIO — captures generator stdout during the sandbox run
9
+ require "open3" # Open3.capture2e — captures `ruby -c` output+status for `lint`
9
10
  require_relative "port_takeover"
10
11
 
11
12
  module Tina4
@@ -29,7 +30,7 @@ module Tina4
29
30
  # handlers take (name, flags); COMMANDS handlers take (argv).
30
31
 
31
32
  GENERATORS = {
32
- "model" => { handler: :generate_model, usage: '<Name> [--fields "name:string,price:float"]', summary: "ORM model + matching migration" },
33
+ "model" => { handler: :generate_model, usage: '<Name> [--fields "name:string,price:float"] [--table-name <name>]', summary: "ORM model + matching migration" },
33
34
  "route" => { handler: :generate_route, usage: "<name> [--model Name] [--public]", summary: "CRUD route file, secure by default (--public opens writes)" },
34
35
  "crud" => { handler: :generate_crud, usage: '<Name> [--fields "..."] [--public]', summary: "Model + migration + routes + form + view + test" },
35
36
  "migration" => { handler: :generate_migration, usage: "<description>", summary: "Timestamped migration file (UP/DOWN)" },
@@ -70,6 +71,7 @@ module Tina4
70
71
  "test" => { handler: :cmd_test, summary: "Run inline tests" },
71
72
  "queue" => { handler: :cmd_queue, usage: "<work|stats|retry|clear> [topic]", subcommands: QUEUE_SUBCOMMANDS.keys, summary: "Run queue workers and manage jobs" },
72
73
  "build" => { handler: :cmd_build, usage: "[--tag NAME] [--file PATH]", summary: "Build the deployable Docker image" },
74
+ "lint" => { handler: :cmd_lint, usage: "[--fix] [--no-install]", summary: "Lint src/ + app.rb (rubocop, installed dev-only on demand, else ruby -c)" },
73
75
  "version" => { handler: :cmd_version, summary: "Show Tina4 version" },
74
76
  "routes" => { handler: :cmd_routes, summary: "List all registered routes" },
75
77
  "console" => { handler: :cmd_console, summary: "Start an interactive console" },
@@ -282,6 +284,52 @@ module Tina4
282
284
  pluralize_table(to_snake_case(name))
283
285
  end
284
286
 
287
+ # The table name a generator uses, honouring `--table-name` and speaking up
288
+ # instead of renaming silently (issue #123). Mirrors `_resolve_table` in
289
+ # tina4-python/tina4_python/cli/__init__.py:98.
290
+ #
291
+ # `announce` prints the note/warning; it is TRUE only for `generate_model`
292
+ # (where the table is born). Composite generators (crud) let the model
293
+ # sub-call announce, and generators that target an EXISTING table
294
+ # (seeder/form/view) still honour `--table-name` but stay quiet so the note
295
+ # is not repeated. `build_model_resolution` calls it with announce:false too,
296
+ # so the envelope's table_name/migration_path equal what lands on disk.
297
+ #
298
+ # * `--table-name <name>` wins verbatim. If that name is ITSELF a reserved
299
+ # word, warn loudly: Tina4 interpolates table names UNQUOTED, so the ORM's
300
+ # generated SQL will fail on it and quoting it in raw SQL + migrations is
301
+ # now the developer's job (we do NOT silently quote — identifier quoting is
302
+ # a global storage invariant, not a local fix).
303
+ # * Otherwise fall back to `to_table_name`. When that auto-pluralises a
304
+ # reserved-word class name (Order -> orders), print a one-line note naming
305
+ # the rename and the `--table-name` escape hatch, so the developer is
306
+ # informed rather than surprised.
307
+ def resolve_table(name, flags = {}, announce: false)
308
+ flags ||= {}
309
+ override = flags["table-name"]
310
+ # A real value only — a bare `--table-name` parses to `true` and an empty
311
+ # `--table-name ""` to "", both of which fall through to the pluralise path
312
+ # (parity with Python's `if override and not isinstance(override, bool)`).
313
+ if override.is_a?(String) && !override.empty?
314
+ if announce && SQL_RESERVED_TABLE_NAMES.include?(to_snake_case(override))
315
+ puts " ! table_name '#{override}' is a SQL reserved word. Tina4 interpolates " \
316
+ "table names UNQUOTED, so the ORM's generated SQL will fail on it -- " \
317
+ "quote it yourself in raw SQL and migrations."
318
+ end
319
+ return override
320
+ end
321
+ bare = to_snake_case(name)
322
+ if SQL_RESERVED_TABLE_NAMES.include?(bare)
323
+ table = pluralize_table(bare)
324
+ if announce
325
+ puts " · '#{bare}' is a SQL reserved word; using table_name '#{table}' " \
326
+ "(Tina4 interpolates table names unquoted). Override with --table-name <name>."
327
+ end
328
+ return table
329
+ end
330
+ bare
331
+ end
332
+
285
333
  # Called without --fields, the generators fall back to a single `name`
286
334
  # string column. That default MUST be materialised here, in one place, and
287
335
  # then flow into the model, the migration, the form, the view and the spec
@@ -516,7 +564,7 @@ module Tina4
516
564
  # Parse --key value and --flag from args. Returns [flags_hash, positional_array]
517
565
  def parse_flags(args)
518
566
  # Boolean-only flags that never take a value argument
519
- boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration once dry-run]
567
+ boolean_flags = %w[no-browser no-reload production managed all clear dev json public no-migration once dry-run fix no-install]
520
568
 
521
569
  flags = {}
522
570
  positional = []
@@ -1206,6 +1254,126 @@ module Tina4
1206
1254
  puts " Run: docker run -p 7147:7147 #{tag}"
1207
1255
  end
1208
1256
 
1257
+ # ── lint ──────────────────────────────────────────────────────────────
1258
+ #
1259
+ # Lint the project's source. The framework ships NO linter (so a Tina4 app
1260
+ # stays zero-dependency); `tina4ruby lint` uses the project's own rubocop and
1261
+ # INSTALLS it as a DEV dependency on demand when it is absent. Layers:
1262
+ #
1263
+ # * rubocop present (bundled via the project Gemfile, or on PATH): run it.
1264
+ # `--fix` runs `rubocop -a` (safe autocorrect). rubocop reports syntax
1265
+ # too, so it is the whole pass when present.
1266
+ # * rubocop absent: silently `bundle add rubocop --group development` --
1267
+ # running `tina4ruby lint` is the consent to add it -- then run it. It
1268
+ # lands in the development group only, never the app's runtime
1269
+ # dependencies. `--no-install` skips this.
1270
+ # * Baseline (zero dependency): the built-in `ruby -c` syntax parse -- no
1271
+ # execution, nothing written, no dependency. Used with `--no-install` or
1272
+ # when the install cannot run (no bundler/Gemfile, offline).
1273
+ #
1274
+ # Contract (identical across all four frameworks): exit 0 = clean, non-zero =
1275
+ # findings; the summary names the tool that ran in [brackets]. Scope is the
1276
+ # user's app (`src/` recursively + `app.rb`), mirroring how `tina4ruby test`
1277
+ # runs the project's own specs -- not the framework's code. Ruby mirror of
1278
+ # the Python master's `_lint` (tina4-python/tina4_python/cli/__init__.py).
1279
+ #
1280
+ # tina4ruby lint # rubocop (installed dev-only on demand), else baseline
1281
+ # tina4ruby lint --fix # rubocop -a
1282
+ # tina4ruby lint --no-install # rubocop if already present, else the syntax baseline
1283
+ def cmd_lint(argv)
1284
+ flags, _positional = parse_flags(argv)
1285
+ fix = flags["fix"] == true
1286
+ no_install = flags["no-install"] == true
1287
+
1288
+ rb_files = []
1289
+ rb_files.concat(Dir.glob(File.join("src", "**", "*.rb")).sort) if File.directory?("src")
1290
+ rb_files << "app.rb" if File.file?("app.rb")
1291
+
1292
+ if rb_files.empty?
1293
+ puts " lint: nothing to lint (no src/ or app.rb)."
1294
+ exit 0
1295
+ end
1296
+
1297
+ rubocop = resolve_rubocop
1298
+
1299
+ # Silent on-demand install: running `tina4ruby lint` is the consent to add
1300
+ # rubocop as a DEV dependency of the project. --no-install opts out (CI /
1301
+ # offline) and falls through to the zero-dependency baseline. Needs bundler
1302
+ # AND a Gemfile to mutate.
1303
+ if rubocop.nil? && !no_install
1304
+ bundle = which_executable("bundle")
1305
+ if bundle && File.file?("Gemfile")
1306
+ puts " · rubocop not found -- adding it as a dev dependency (bundle add rubocop --group development)..."
1307
+ if system(bundle, "add", "rubocop", "--group", "development")
1308
+ rubocop = resolve_rubocop
1309
+ else
1310
+ puts " · could not install rubocop -- using the zero-dependency syntax baseline."
1311
+ end
1312
+ end
1313
+ end
1314
+
1315
+ if rubocop
1316
+ label = fix ? "rubocop -a" : "rubocop"
1317
+ command = rubocop + (fix ? ["-a"] : []) + rb_files
1318
+ ok = system(*command) # inherits stdio — rubocop prints its own report
1319
+ if ok
1320
+ puts " ✓ lint clean -- #{rb_files.length} file(s) [#{label}]"
1321
+ exit 0
1322
+ end
1323
+ puts " ✗ lint failed -- #{rb_files.length} file(s) [#{label}]"
1324
+ exit 1
1325
+ end
1326
+
1327
+ # Zero-dependency baseline: the built-in `ruby -c` syntax parse. No
1328
+ # execution, nothing written, no dependency added.
1329
+ puts " · syntax baseline has no autofix (--no-install, or rubocop unavailable)." if fix
1330
+
1331
+ syntax_errors = 0
1332
+ rb_files.each do |rel|
1333
+ output, status = Open3.capture2e("ruby", "-c", rel)
1334
+ next if status.success? # "Syntax OK"
1335
+
1336
+ syntax_errors += 1
1337
+ # `ruby -c` names the file+line in its first diagnostic line
1338
+ # (e.g. "ruby: src/broken.rb:2: syntax errors found (SyntaxError)"); drop
1339
+ # the redundant leading "ruby: " so the shape matches the Python master's
1340
+ # "file:lineno: msg".
1341
+ first_line = output.strip.sub(/\Aruby:\s*/, "").lines.first.to_s.rstrip
1342
+ first_line = "#{rel}: syntax error" if first_line.empty?
1343
+ puts " ✗ #{first_line}"
1344
+ end
1345
+
1346
+ if syntax_errors.positive?
1347
+ puts " ✗ lint failed -- #{syntax_errors} syntax error(s) in #{rb_files.length} file(s) [ruby -c]"
1348
+ exit 1
1349
+ end
1350
+ puts " ✓ lint clean -- #{rb_files.length} file(s) [ruby -c, syntax only]"
1351
+ exit 0
1352
+ end
1353
+
1354
+ # Resolve a runnable rubocop for the rich lint pass. Returns the argv PREFIX
1355
+ # to invoke it (`[<bundle>, "exec", "rubocop"]` when the project's Gemfile
1356
+ # bundles it — the version the project pins — or `[<rubocop-path>]` on PATH),
1357
+ # or nil to fall back to the `ruby -c` baseline. Mirrors the Python master's
1358
+ # `_resolve_ruff` (resolve the project's OWN linter). The bundler probe
1359
+ # (`bundle exec rubocop --version`) is a REAL resolve check: exit 0 only when
1360
+ # the gem is actually in the bundle. Bundler is preferred over a bare PATH
1361
+ # rubocop because inside a bundled project the pinned version is the correct
1362
+ # one (and a bare `rubocop` may trip Bundler's "not part of the bundle").
1363
+ def resolve_rubocop
1364
+ # Only the PROJECT's own bundled rubocop counts -- linting must be
1365
+ # reproducible from the project's pinned dev dependency, NOT a stray global
1366
+ # rubocop on PATH (which would also make the on-demand install
1367
+ # non-deterministic: a global tool would preempt it). When the project has
1368
+ # none, the caller installs it dev-only via `bundle add`.
1369
+ bundle = which_executable("bundle")
1370
+ if bundle && File.file?("Gemfile") &&
1371
+ system(bundle, "exec", "rubocop", "--version", out: File::NULL, err: File::NULL)
1372
+ return [bundle, "exec", "rubocop"]
1373
+ end
1374
+ nil
1375
+ end
1376
+
1209
1377
  # Locate an executable on PATH — the stdlib-only equivalent of Python's
1210
1378
  # shutil.which (no new gem). Honours PATHEXT on Windows.
1211
1379
  def which_executable(cmd)
@@ -1494,21 +1662,19 @@ module Tina4
1494
1662
 
1495
1663
  def build_model_resolution(name, flags)
1496
1664
  raw = to_snake_case(name)
1665
+ # Honour --table-name (silent here; generate_model announces). The envelope
1666
+ # thus reports the SAME table_name/migration_path the generator writes.
1667
+ table = resolve_table(name, flags)
1497
1668
  transformations = []
1498
- table =
1499
- if SQL_RESERVED_TABLE_NAMES.include?(raw)
1500
- plural = pluralize_table(raw)
1501
- transformations << {
1502
- "kind" => "reserved_word_pluralize",
1503
- "from" => raw,
1504
- "to" => plural,
1505
- "reason" => "SQL reserved word '#{raw}' would break CREATE TABLE",
1506
- "override" => "--table #{raw} --quote (requires quoted-identifier mode, not yet implemented)"
1507
- }
1508
- plural
1509
- else
1510
- raw
1511
- end
1669
+ if SQL_RESERVED_TABLE_NAMES.include?(raw) && raw != table
1670
+ transformations << {
1671
+ "kind" => "reserved_word_pluralize",
1672
+ "from" => raw,
1673
+ "to" => table,
1674
+ "reason" => "SQL reserved word '#{raw}' would break CREATE TABLE",
1675
+ "override" => "--table-name <name> (table names interpolate unquoted; forcing a reserved name is yours to quote in raw SQL)"
1676
+ }
1677
+ end
1512
1678
  # The migration filename embeds a timestamp; predict it deterministically
1513
1679
  # so a `--dry-run` and the real run agree on the same second. Two
1514
1680
  # generations within the same second collide anyway (existing behaviour).
@@ -1675,8 +1841,9 @@ module Tina4
1675
1841
  pluralized = resolution["transformations"].find { |t| t["kind"] == "reserved_word_pluralize" }
1676
1842
  if pluralized
1677
1843
  lines << ""
1678
- lines << " To keep the raw name '#{pluralized['from']}' as the table:"
1679
- lines << " tina4ruby generate #{target} #{name} --table #{pluralized['from']} --quote (opt-in, ADR-0062 forthcoming)"
1844
+ lines << " To set the table name yourself:"
1845
+ lines << " tina4ruby generate #{target} #{name} --table-name <name>"
1846
+ lines << " Tina4 interpolates table names unquoted; if you force the reserved '#{pluralized['from']}', you own the quoting in raw SQL."
1680
1847
  end
1681
1848
  # ADR-0063 v1.1: surface `edit_hints[]` under "Edit these lines:" and
1682
1849
  # `next[]` under "Next:" — both additive; missing / empty arrays elide
@@ -1725,7 +1892,9 @@ module Tina4
1725
1892
 
1726
1893
  def generate_model(name, flags, emit_test: true)
1727
1894
  fields = fields_or_default(flags["fields"])
1728
- table = to_table_name(name)
1895
+ # announce: this is where the table is born, so a reserved-word pluralise
1896
+ # (Order -> orders) or a forced reserved --table-name speaks up (#123).
1897
+ table = resolve_table(name, flags, announce: true)
1729
1898
  snake = to_snake_case(name)
1730
1899
 
1731
1900
  # Build field lines
@@ -1971,7 +2140,8 @@ module Tina4
1971
2140
  # ── Generator: crud ──────────────────────────────────────────────────
1972
2141
 
1973
2142
  def generate_crud(name, flags)
1974
- table = to_table_name(name)
2143
+ # Quiet here — the generate_model sub-call below announces once (#123).
2144
+ table = resolve_table(name, flags)
1975
2145
  # Always derive the plural route from the CLASS NAME, not by appending
1976
2146
  # "s" to the table — otherwise a reserved-word table (already plural,
1977
2147
  # Order -> orders) becomes orderss, and a `y`-ending class (Category)
@@ -2300,7 +2470,8 @@ module Tina4
2300
2470
 
2301
2471
  def generate_form(name, flags = {})
2302
2472
  fields = fields_or_default(flags["fields"])
2303
- table = to_table_name(name)
2473
+ # Targets an existing table honour --table-name but stay quiet (#123).
2474
+ table = resolve_table(name, flags)
2304
2475
  route_name = to_route_name(name)
2305
2476
 
2306
2477
  # Input type mapping
@@ -2383,7 +2554,8 @@ module Tina4
2383
2554
 
2384
2555
  def generate_view(name, flags = {})
2385
2556
  fields = fields_or_default(flags["fields"])
2386
- table = to_table_name(name)
2557
+ # Targets an existing table honour --table-name but stay quiet (#123).
2558
+ table = resolve_table(name, flags)
2387
2559
  route_name = to_route_name(name)
2388
2560
 
2389
2561
  cols = fields.map { |f, _| f }
@@ -2816,7 +2988,8 @@ module Tina4
2816
2988
  #
2817
2989
  # tina4ruby generate seeder Product
2818
2990
  def generate_seeder(name, flags = {})
2819
- table = to_table_name(name)
2991
+ # Targets an existing table honour --table-name but stay quiet (#123).
2992
+ table = resolve_table(name, flags)
2820
2993
  model_snake = to_snake_case(name)
2821
2994
  dir = "seeds"
2822
2995
  FileUtils.mkdir_p(dir)
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.127"
4
+ VERSION = "3.13.129"
5
5
  end
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.127
4
+ version: 3.13.129
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-29 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -276,20 +276,6 @@ dependencies:
276
276
  - - "~>"
277
277
  - !ruby/object:Gem::Version
278
278
  version: '3.12'
279
- - !ruby/object:Gem::Dependency
280
- name: rubocop
281
- requirement: !ruby/object:Gem::Requirement
282
- requirements:
283
- - - "~>"
284
- - !ruby/object:Gem::Version
285
- version: '1.50'
286
- type: :development
287
- prerelease: false
288
- version_requirements: !ruby/object:Gem::Requirement
289
- requirements:
290
- - - "~>"
291
- - !ruby/object:Gem::Version
292
- version: '1.50'
293
279
  - !ruby/object:Gem::Dependency
294
280
  name: openapi3_parser
295
281
  requirement: !ruby/object:Gem::Requirement