tina4ruby 3.13.119 → 3.13.121

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: af7514098ead4805ac18f09d61f1e816ed27dd28b0a37162f4f579ffea87ecca
4
- data.tar.gz: b6742dd30606e26f5d473bf3768dbd68a33286f1f95934aaa63720069d0a0430
3
+ metadata.gz: 5432ec631264ef420f357f5b662a67ae7d8a22d8f32dfc719dc568e2713f0320
4
+ data.tar.gz: 0bfad1aff49e4b87548dc0d2ec13b27671295205189404252615810a245b89eb
5
5
  SHA512:
6
- metadata.gz: fb0d3ba8a09a83d1d207e716ebc97a79e7d142ccfeaaa5c862fa59ba338df98af9f1114d696c2dc7f15f144a63b2f0896607d317b0bf7b49332f01c1a02e56d1
7
- data.tar.gz: d86833f86c8e9a90a1510140486b2688d6df712b5d3db281f5dd57d1a6bea02bf4a73a63a5bb03b8f0078fd27b6d3d57ee1cf88eab7679eeb820c95846d2dc32
6
+ metadata.gz: 8a2875da36ab669f4504833a97c65f85b0f37b48bd622ce692c92f567e3b65bf778950a80167b1e18df1e6617fa9bd4c39008f5efa5d0a6c624ef499789931d2
7
+ data.tar.gz: d090ea23d3fc4a07e1f0d249970e06300e76718464357ea815937136699b4da9daec59d4d37f425a9a0daa9e2e76698b3eea7d17ebfc6f24ebd1a8d77cb3a2d2
data/lib/tina4/cli.rb CHANGED
@@ -4,6 +4,8 @@ require "optparse"
4
4
  require "fileutils"
5
5
  require "set"
6
6
  require "json"
7
+ require "tmpdir" # Dir.mktmpdir — used by the ADR-0063 sandbox edit-hint scan
8
+ require "stringio" # StringIO — captures generator stdout during the sandbox run
7
9
  require_relative "port_takeover"
8
10
 
9
11
  module Tina4
@@ -289,6 +291,128 @@ module Tina4
289
291
  # created_at. The first write then failed with "no such column: name".
290
292
  DEFAULT_FIELDS = [["name", "string"]].freeze
291
293
 
294
+ # ADR-0063 (scaffolding envelope v1.1): `# tina4:edit <label>` in a generated
295
+ # Ruby file, `-- tina4:edit <label>` in a generated SQL file, or
296
+ # `{# tina4:edit <label> #}` in a generated Twig template marks a
297
+ # first-edit spot for a developer / coding agent to fill in. The scanner
298
+ # surfaces every match as an `edit_hints` entry in the `generate_v1_1`
299
+ # envelope AND under "Edit these lines:" in the human stderr block.
300
+ #
301
+ # One regex covers all three comment styles - `//`-style (Ruby `#`), SQL
302
+ # (`--`), and Twig (`{# ... #}`) - so the same scanner drives
303
+ # model / migration / middleware / route AND the form + view Twig
304
+ # generators (ADR-0063 twig-scanner parity, 3.13.121). The optional
305
+ # `(?:\s*#\})?` tail strips the Twig closing sequence so the captured
306
+ # label stays clean ("add fields here", not "add fields here #}").
307
+ # Ported verbatim from tina4-php's `collectEditHintsFromContent`.
308
+ EDIT_MARKER_RE = %r{(?://|#|--|\{\#)\s*tina4:edit[ \t]+(.+?)(?:\s*\#\})?\s*$}.freeze
309
+
310
+ # ADR-0063 next-step catalogue: 5 short actionable lines per resolution-aware
311
+ # verb. Rendered under "Next:" in the human block AND surfaced as
312
+ # `resolution.next[]` in the envelope. Each entry is a `Proc` that consumes
313
+ # the resolution so paths / class names substitute correctly. Curated (not
314
+ # generated) so a caller sees consistent, sensible next steps.
315
+ NEXT_STEPS = {
316
+ "model" => lambda do |res|
317
+ table = res["table_name"] || "resource"
318
+ class_name = res["class_name"] || "Model"
319
+ file_path = res["file_path"] || "src/orm/#{table}.rb"
320
+ route = (res["routes"] || []).first || "/#{table}"
321
+ [
322
+ "Edit #{file_path} to add fields beyond the default 'name'",
323
+ "Apply the migration: tina4ruby migrate",
324
+ "Try it: tina4ruby serve -> curl http://localhost:7147#{route}",
325
+ "Add CRUD scaffolding: tina4ruby generate crud #{class_name} --skip-model",
326
+ "Seed sample data: tina4ruby generate seeder #{class_name} && tina4ruby seed",
327
+ ]
328
+ end,
329
+ "route" => lambda do |res|
330
+ file_path = res["file_path"] || "src/routes/resource.rb"
331
+ route = (res["routes"] || []).first || "/api/resource"
332
+ test_path = (res["test_paths"] || []).first || "spec/routes/resource_spec.rb"
333
+ [
334
+ "Edit #{file_path} to customise the response",
335
+ "Try it: tina4ruby serve -> curl http://localhost:7147#{route}",
336
+ "Add real assertions: #{test_path}",
337
+ "Bearer-gate is on: writes require a Bearer token (rerun with --public to open)",
338
+ "Browse the API: tina4ruby serve -> http://localhost:7147/swagger",
339
+ ]
340
+ end,
341
+ "migration" => lambda do |res|
342
+ file_path = res["file_path"] || "migrations/new_migration.sql"
343
+ down_path = file_path.sub(/\.sql$/, ".down.sql")
344
+ [
345
+ "Edit #{file_path} to add columns beyond id + created_at",
346
+ "Mirror the change in: #{down_path}",
347
+ "Apply it: tina4ruby migrate",
348
+ "Check status: tina4ruby migrate status",
349
+ "Rollback if wrong: tina4ruby migrate rollback",
350
+ ]
351
+ end,
352
+ "middleware" => lambda do |res|
353
+ class_name = res["class_name"] || "Middleware"
354
+ file_path = res["file_path"] || "src/middleware/middleware.rb"
355
+ [
356
+ "Edit #{file_path} to guard the request or shape the response",
357
+ "Wire it into a route: middleware: [#{class_name}] on Tina4.get/post/...",
358
+ "Wire it globally: Tina4::Router.use(#{class_name})",
359
+ "Prove the gate: add a real spec covering allow + deny",
360
+ "Chain more: order in the middleware: [] list is registration order",
361
+ ]
362
+ end,
363
+ "form" => lambda do |res|
364
+ class_name = res["class_name"] || "Form"
365
+ file_path = res["file_path"] || "src/templates/forms/form.twig"
366
+ route = (res["routes"] || []).first || "/#{to_snake_case_static(class_name)}"
367
+ [
368
+ "Edit #{file_path} to restyle / add fields to the form",
369
+ "Render it: response.render(\"forms/#{File.basename(file_path)}\", { item: item })",
370
+ "Wire it to a route: tina4ruby generate route #{class_name}",
371
+ "Add a matching model: tina4ruby generate model #{class_name}",
372
+ "Try it: tina4ruby serve -> http://localhost:7147#{route}",
373
+ ]
374
+ end,
375
+ "view" => lambda do |res|
376
+ class_name = res["class_name"] || "View"
377
+ list_path = res["file_path"] || "src/templates/pages/list.twig"
378
+ detail_path = (res["transformations"] || []).find { |t| t["kind"] == "detail_view" }
379
+ detail_path = detail_path ? detail_path["to"] : list_path.sub(/list\.twig$/, "detail.twig")
380
+ route = (res["routes"] || []).first || "/#{to_snake_case_static(class_name)}"
381
+ [
382
+ "Edit #{list_path} for the list view (add sort / filter / pagination)",
383
+ "Edit #{detail_path} for the detail view (add related records / actions)",
384
+ "Wire it to a route: tina4ruby generate route #{class_name}",
385
+ "Add a matching model: tina4ruby generate model #{class_name}",
386
+ "Try it: tina4ruby serve -> http://localhost:7147#{route}",
387
+ ]
388
+ end,
389
+ }.freeze
390
+
391
+ # Static helper for the NEXT_STEPS lambdas above - avoids reaching for the
392
+ # per-instance to_snake_case when only the class name is known.
393
+ def self.to_snake_case_static(name)
394
+ s = name.to_s
395
+ s.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
396
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
397
+ .downcase
398
+ end
399
+
400
+ # ADR-0063: freeze `Time.now` for the duration of one `cmd_generate` call.
401
+ # Both the resolution builders (`build_migration_resolution`,
402
+ # `build_model_resolution`) and the actual generator (`generate_migration`)
403
+ # embed a timestamp in the migration filename. If they call `Time.now`
404
+ # independently, the sandbox pre-run (which drives edit_hints for dry-run
405
+ # mode) and the real run can pick different seconds, and the envelope's
406
+ # `file_path` would drift from the file that actually gets written. One
407
+ # memoised timestamp per command run fixes that.
408
+ def generate_timestamp
409
+ @generate_timestamp ||= Time.now
410
+ end
411
+
412
+ def reset_generate_timestamp!
413
+ @generate_timestamp = nil
414
+ end
415
+
292
416
  # Parsed --fields, or the default single `name` column when none given.
293
417
  def fields_or_default(fields_str)
294
418
  parsed = parse_fields(fields_str)
@@ -331,7 +455,11 @@ module Tina4
331
455
  bar = "─" * 60
332
456
  head = "#{indent}# ─── AI-FILL: #{fn} "
333
457
  head += "─" * [4, 66 - head.length].max
334
- lines = [head, "#{indent}# Intent: #{intent}"]
458
+ # ADR-0063: prepend a `# tina4:edit <label>` machine-readable pointer so a
459
+ # tool can surface WHERE this stub needs custom logic without parsing the
460
+ # AI-FILL banner. `intent` is the developer-facing TODO — reuse it as the
461
+ # label (short, actionable, no period).
462
+ lines = ["#{indent}# tina4:edit #{intent}", head, "#{indent}# Intent: #{intent}"]
335
463
  lines << "#{indent}# Given: #{given}" if given
336
464
  lines << "#{indent}# Use: #{use}"
337
465
  lines << "#{indent}# Return: #{ret}" if ret
@@ -348,7 +476,10 @@ module Tina4
348
476
  def extend_marker(note, hint = "", indent: " ")
349
477
  head = "#{indent}# ─── EXTEND: #{note} "
350
478
  head += "─" * [4, 66 - head.length].max
351
- out = head + "\n"
479
+ # ADR-0063: `# tina4:edit <label>` is the machine-readable partner of the
480
+ # ─── EXTEND banner. `note` is the human-shaped TODO — reuse it verbatim.
481
+ out = "#{indent}# tina4:edit #{note}\n"
482
+ out += head + "\n"
352
483
  out += "#{indent}# #{hint}\n" unless hint.to_s.empty?
353
484
  out
354
485
  end
@@ -602,22 +733,52 @@ module Tina4
602
733
 
603
734
  # ── migrate:create ───────────────────────────────────────────────────
604
735
 
605
- # Create a new timestamped migration file (UP .sql + .down.sql), matching
606
- # the Python master `migrate:create`. CHEAP + database-free: it only writes
607
- # files via the static Tina4::Migration.create_migration helper no app
608
- # boot, no DB connection, no tracking table. `description` is every arg
609
- # joined with a space (parity with Python's `" ".join(args)`).
736
+ # Create a new timestamped migration file (UP .sql + .down.sql). The
737
+ # description can be human prose ("add users table") — it is slugified into
738
+ # a snake_case name ("add_users_table") and then handed to the SAME
739
+ # resolution-aware generator that backs `tina4ruby generate migration`, so
740
+ # both CLI paths emit the ADR-0063 `generate_v1_1` envelope with matching
741
+ # `edit_hints[]`, `next[]`, human resolution block, and the same
742
+ # `# tina4:edit` markers in the generated .sql files. The only intentional
743
+ # difference is `emit_test: false` — `migrate:create` never co-emits a
744
+ # test file, preserving its "just a migration" semantics. CHEAP +
745
+ # database-free: it never boots the app, opens a DB, or writes a tracking
746
+ # row.
747
+ #
748
+ # Accepts `--json` and `--dry-run` (same as `generate migration`); anything
749
+ # else is treated as description tokens joined with a space (parity with
750
+ # Python's `" ".join(args)`).
610
751
  def cmd_migrate_create(argv)
611
- description = (argv || []).join(" ").strip
752
+ argv = argv || []
753
+ flags, positional = parse_flags(argv)
754
+ json_mode = flags.delete("json") ? true : false
755
+ dry_run = flags.delete("dry-run") ? true : false
756
+
757
+ description = positional.join(" ").strip
612
758
  if description.empty?
613
759
  puts "Usage: tina4ruby migrate:create <description>"
614
760
  exit 1
615
761
  end
616
762
 
617
- require_relative "log"
618
- require_relative "migration"
619
- path = Tina4::Migration.create_migration(description, migrations_dir: "migrations")
620
- puts "Created: #{path}"
763
+ # Slugify the human description into a snake_case migration NAME so the
764
+ # filename matches what `generate migration <name>` would produce, and
765
+ # so the generator's `create_X` schema-awareness fires when the
766
+ # description starts with "create <thing>". Regex + downcase mirrors the
767
+ # Python master's `re.sub(r'[^a-z0-9]+', '_', desc, flags=re.I).lower()`
768
+ # convention that the four frameworks share for migration filenames.
769
+ name = description.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
770
+
771
+ # Fresh timestamp per command run so the sandbox pre-run (edit_hints)
772
+ # and the real generator agree on the same second — ADR-0063.
773
+ reset_generate_timestamp!
774
+
775
+ run_resolution_aware_generator(
776
+ "migration", name, flags,
777
+ json_mode: json_mode,
778
+ dry_run: dry_run,
779
+ gen_spec: GENERATORS["migration"],
780
+ generator_kwargs: { emit_test: false, description: description }
781
+ )
621
782
  end
622
783
 
623
784
  # ── migrate:status ─────────────────────────────────────────────────────
@@ -1117,6 +1278,10 @@ module Tina4
1117
1278
  # will produce before it runs.
1118
1279
 
1119
1280
  def cmd_generate(argv)
1281
+ # ADR-0063: fresh timestamp per command run so the sandbox pre-run + the
1282
+ # real generator both agree on the migration filename.
1283
+ reset_generate_timestamp!
1284
+
1120
1285
  what = argv.shift
1121
1286
  all = GENERATORS.keys.join(", ") # single source: the GENERATORS registry
1122
1287
 
@@ -1126,7 +1291,7 @@ module Tina4
1126
1291
  puts ' Options: --fields "name:string,price:float" --model ModelName'
1127
1292
  puts ' --public open a route'"'"'s writes (default: secure)'
1128
1293
  puts ' --every 5m | --cron "..." service schedule'
1129
- puts ' --json emit the generate_v1 resolution envelope'
1294
+ puts ' --json emit the generate_v1_1 resolution envelope'
1130
1295
  puts ' --dry-run plan the scaffold; write no files'
1131
1296
  exit 1
1132
1297
  end
@@ -1155,18 +1320,49 @@ module Tina4
1155
1320
  exit 1
1156
1321
  end
1157
1322
 
1158
- # Only the four resolution-aware targets emit a resolution + JSON envelope;
1323
+ # Only the resolution-aware targets emit a resolution + JSON envelope;
1159
1324
  # every other generator keeps its existing behaviour so nothing regresses.
1160
- resolution_aware = %w[model route migration middleware]
1325
+ # ADR-0063 (3.13.121): form + view joined so their baked `{# tina4:edit ... #}`
1326
+ # markers surface in resolution.edit_hints[] under the same v1.1 envelope.
1327
+ resolution_aware = %w[model route migration middleware form view]
1161
1328
  if !resolution_aware.include?(what)
1162
1329
  send(gen_spec[:handler], name, flags)
1163
1330
  return
1164
1331
  end
1165
1332
 
1333
+ run_resolution_aware_generator(
1334
+ what, name, flags,
1335
+ json_mode: json_mode, dry_run: dry_run, gen_spec: gen_spec
1336
+ )
1337
+ end
1338
+
1339
+ # Shared entry point for the four resolution-aware generators (model,
1340
+ # route, migration, middleware) — used by BOTH `cmd_generate` and by
1341
+ # `cmd_migrate_create`, so both CLI paths emit the SAME ADR-0063 envelope
1342
+ # (`edit_hints[]`, `next[]`, human resolution block, `# tina4:edit`
1343
+ # markers). `generator_kwargs` are forwarded verbatim to the generator
1344
+ # method — that is how `cmd_migrate_create` passes `emit_test: false` and
1345
+ # `description:` without duplicating the whole envelope-emission path.
1346
+ def run_resolution_aware_generator(what, name, flags,
1347
+ json_mode:, dry_run:,
1348
+ gen_spec: nil,
1349
+ generator_kwargs: {})
1350
+ gen_spec ||= GENERATORS[what]
1351
+
1166
1352
  resolution = build_generate_resolution(what, name, flags)
1353
+ # ADR-0063 additive v1.1 keys: curated next-steps (pure fn of resolution)
1354
+ # and edit_hints (scan of generated files for `# tina4:edit <label>`).
1355
+ resolution["next"] = build_next_steps(what, resolution)
1356
+ resolution["edit_hints"] =
1357
+ collect_edit_hints_via_sandbox(gen_spec, name, flags, **generator_kwargs)
1167
1358
 
1168
1359
  if json_mode
1169
- actions_taken = dry_run ? [] : run_generator_capturing_actions(gen_spec, name, flags)
1360
+ actions_taken =
1361
+ if dry_run
1362
+ []
1363
+ else
1364
+ run_generator_capturing_actions(gen_spec, name, flags, **generator_kwargs)
1365
+ end
1170
1366
  envelope = build_generate_envelope(what, name, flags, resolution, actions_taken, dry_run)
1171
1367
  puts JSON.pretty_generate(envelope)
1172
1368
  return
@@ -1179,8 +1375,84 @@ module Tina4
1179
1375
  if dry_run
1180
1376
  $stderr.puts " (dry-run — no files written)"
1181
1377
  else
1182
- send(gen_spec[:handler], name, flags)
1378
+ send(gen_spec[:handler], name, flags, **generator_kwargs)
1379
+ end
1380
+ end
1381
+
1382
+ # ADR-0063 helpers ────────────────────────────────────────────────────────
1383
+
1384
+ # Curated per-verb next-step list. Pure function of the resolution; never
1385
+ # scans the filesystem, so it works identically in dry-run and real-run.
1386
+ def build_next_steps(target, resolution)
1387
+ builder = NEXT_STEPS[target]
1388
+ return [] unless builder
1389
+ Array(builder.call(resolution))
1390
+ end
1391
+
1392
+ # Scan a list of files for `# tina4:edit <label>` (Ruby) or
1393
+ # `-- tina4:edit <label>` (SQL) lines. Returns `[{file, line, label}]` in
1394
+ # the same order the files were passed. Missing / unreadable files are
1395
+ # skipped silently (a scaffold that already exists on disk simply produces
1396
+ # no hint for that path — that is the intended behaviour).
1397
+ def scan_edit_hints_from_files(paths)
1398
+ hints = []
1399
+ paths.each do |path|
1400
+ next unless path
1401
+ next unless File.file?(path)
1402
+ begin
1403
+ content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace)
1404
+ rescue SystemCallError
1405
+ next
1406
+ end
1407
+ content.each_line.with_index(1) do |line, lineno|
1408
+ next unless (m = line.match(EDIT_MARKER_RE))
1409
+ hints << { "file" => path, "line" => lineno, "label" => m[1].strip }
1410
+ end
1411
+ end
1412
+ hints
1413
+ end
1414
+
1415
+ # Run the generator inside a throw-away tmpdir with `$stdout` captured, then
1416
+ # walk every written file and scan for `# tina4:edit` / `-- tina4:edit`
1417
+ # markers. Used for BOTH dry-run and real-run modes so the envelope always
1418
+ # ships accurate `edit_hints` before any live-cwd write happens (preserves
1419
+ # the "Ctrl-C between plan and write" contract of the human block).
1420
+ #
1421
+ # `generate_timestamp` is shared with the real run so a migration file
1422
+ # sandboxed here has the SAME timestamped basename as the file that lands
1423
+ # in the developer's cwd - the hint's `file` field matches disk verbatim.
1424
+ # `generator_kwargs` are forwarded verbatim to the generator method so the
1425
+ # sandbox pre-run produces the same files (and therefore the same edit
1426
+ # markers) as the real run — used by `cmd_migrate_create` to thread
1427
+ # `emit_test: false` + `description:` through without a bespoke sandbox.
1428
+ def collect_edit_hints_via_sandbox(gen_spec, name, flags, **generator_kwargs)
1429
+ hints = []
1430
+ Dir.mktmpdir("tina4_edit_hints") do |sandbox|
1431
+ # Block-form Dir.chdir composes cleanly under an outer block-form
1432
+ # Dir.chdir (Ruby 3.x warns on nested non-block chdir). No `ensure`
1433
+ # cwd-restore needed - the block form guarantees it.
1434
+ Dir.chdir(sandbox) do
1435
+ buffer = StringIO.new
1436
+ original_stdout = $stdout
1437
+ $stdout = buffer
1438
+ begin
1439
+ # A generator failure inside the sandbox must never break the caller's
1440
+ # real run - rescue everything, return whatever hints we managed to
1441
+ # gather. The real run will error the same way if it is going to.
1442
+ send(gen_spec[:handler], name, flags.dup, **generator_kwargs)
1443
+ rescue StandardError, LoadError, ScriptError
1444
+ # swallow - the sandbox is best-effort
1445
+ ensure
1446
+ $stdout = original_stdout
1447
+ end
1448
+ Dir.glob("**/*", File::FNM_DOTMATCH).sort.each do |rel_path|
1449
+ next if [".", ".."].include?(File.basename(rel_path))
1450
+ next unless File.file?(rel_path)
1451
+ hints.concat(scan_edit_hints_from_files([rel_path]))
1452
+ end
1453
+ end
1183
1454
  end
1455
+ hints
1184
1456
  end
1185
1457
 
1186
1458
  # ── generate resolution helpers ────────────────────────────────────────
@@ -1200,6 +1472,8 @@ module Tina4
1200
1472
  when "route" then build_route_resolution(name, flags)
1201
1473
  when "migration" then build_migration_resolution(name, flags)
1202
1474
  when "middleware" then build_middleware_resolution(name, flags)
1475
+ when "form" then build_form_resolution(name, flags)
1476
+ when "view" then build_view_resolution(name, flags)
1203
1477
  else
1204
1478
  { "class_name" => name.to_s, "table_name" => nil, "file_path" => nil,
1205
1479
  "migration_path" => nil, "transformations" => [],
@@ -1227,7 +1501,10 @@ module Tina4
1227
1501
  # The migration filename embeds a timestamp; predict it deterministically
1228
1502
  # so a `--dry-run` and the real run agree on the same second. Two
1229
1503
  # generations within the same second collide anyway (existing behaviour).
1230
- timestamp = Time.now.strftime("%Y%m%d%H%M%S")
1504
+ # ADR-0063: `generate_timestamp` memoises `Time.now` per `cmd_generate`
1505
+ # call so the sandbox pre-run + real run share one second, and the
1506
+ # envelope's `migration_path` matches the file that actually lands.
1507
+ timestamp = generate_timestamp.strftime("%Y%m%d%H%M%S")
1231
1508
  {
1232
1509
  "class_name" => name.to_s,
1233
1510
  "table_name" => table,
@@ -1253,7 +1530,8 @@ module Tina4
1253
1530
  end
1254
1531
 
1255
1532
  def build_migration_resolution(name, _flags)
1256
- timestamp = Time.now.strftime("%Y%m%d%H%M%S")
1533
+ # ADR-0063: shared memoised timestamp (see `generate_timestamp`).
1534
+ timestamp = generate_timestamp.strftime("%Y%m%d%H%M%S")
1257
1535
  base = name.to_s.sub(/^create_/, "").sub(/^add_/, "").sub(/^drop_/, "")
1258
1536
  snake_base = to_snake_case(base)
1259
1537
  path = "migrations/#{timestamp}_#{name}.sql"
@@ -1281,6 +1559,54 @@ module Tina4
1281
1559
  }
1282
1560
  end
1283
1561
 
1562
+ # Form generator writes ONE Twig template in src/templates/forms/<snake>.twig.
1563
+ # `file_path` names that primary file; the scanner walks every generated
1564
+ # file in the sandbox, so the twig `{# tina4:edit ... #}` marker landing
1565
+ # inside it flows into resolution.edit_hints[] regardless.
1566
+ def build_form_resolution(name, _flags)
1567
+ snake = to_snake_case(name)
1568
+ route_name = to_route_name(name)
1569
+ {
1570
+ "class_name" => name.to_s,
1571
+ "table_name" => nil,
1572
+ "file_path" => "src/templates/forms/#{snake}.twig",
1573
+ "migration_path" => nil,
1574
+ "transformations" => [],
1575
+ "routes" => ["/api/#{route_name}"],
1576
+ "test_paths" => []
1577
+ }
1578
+ end
1579
+
1580
+ # View generator writes TWO Twig templates: a list view keyed by the
1581
+ # (plural) route name and a detail view keyed by the (singular) class
1582
+ # snake. `file_path` names the list (the primary entry point); the
1583
+ # detail path is surfaced under `transformations` so a caller / NEXT_STEPS
1584
+ # lambda / human block can find it without re-deriving. Scanner picks up
1585
+ # both files from the sandbox walk regardless of these fields.
1586
+ def build_view_resolution(name, _flags)
1587
+ snake = to_snake_case(name)
1588
+ route_name = to_route_name(name)
1589
+ list_path = "src/templates/pages/#{route_name}.twig"
1590
+ detail_path = "src/templates/pages/#{snake}.twig"
1591
+ {
1592
+ "class_name" => name.to_s,
1593
+ "table_name" => nil,
1594
+ "file_path" => list_path,
1595
+ "migration_path" => nil,
1596
+ "transformations" => [
1597
+ {
1598
+ "kind" => "detail_view",
1599
+ "from" => name.to_s,
1600
+ "to" => detail_path,
1601
+ "reason" => "list view is #{route_name}.twig; detail view keyed by the singular class snake",
1602
+ "override" => nil
1603
+ }
1604
+ ],
1605
+ "routes" => ["/#{route_name}", "/#{route_name}/{id}"],
1606
+ "test_paths" => []
1607
+ }
1608
+ end
1609
+
1284
1610
  def build_generate_envelope(target, name, flags, resolution, actions_taken, dry_run)
1285
1611
  {
1286
1612
  "command" => "generate",
@@ -1313,12 +1639,33 @@ module Tina4
1313
1639
  if resolution["migration_path"] && resolution["migration_path"] != resolution["file_path"]
1314
1640
  lines << " migration #{resolution['migration_path']}"
1315
1641
  end
1642
+ # ADR-0063: surface the pre-existing test_paths (v1 field, only now printed
1643
+ # in the human block) alongside the two new sections.
1644
+ if resolution["test_paths"] && !resolution["test_paths"].empty?
1645
+ lines << " tests #{resolution['test_paths'].join(', ')}"
1646
+ end
1316
1647
  pluralized = resolution["transformations"].find { |t| t["kind"] == "reserved_word_pluralize" }
1317
1648
  if pluralized
1318
1649
  lines << ""
1319
1650
  lines << " To keep the raw name '#{pluralized['from']}' as the table:"
1320
1651
  lines << " tina4ruby generate #{target} #{name} --table #{pluralized['from']} --quote (opt-in, ADR-0062 forthcoming)"
1321
1652
  end
1653
+ # ADR-0063 v1.1: surface `edit_hints[]` under "Edit these lines:" and
1654
+ # `next[]` under "Next:" — both additive; missing / empty arrays elide
1655
+ # cleanly. The `file:line label` shape is grep-friendly for humans and
1656
+ # machine-parseable for tools.
1657
+ if resolution["edit_hints"] && !resolution["edit_hints"].empty?
1658
+ lines << ""
1659
+ lines << " Edit these lines:"
1660
+ resolution["edit_hints"].each do |h|
1661
+ lines << " #{h['file']}:#{h['line']} #{h['label']}"
1662
+ end
1663
+ end
1664
+ if resolution["next"] && !resolution["next"].empty?
1665
+ lines << ""
1666
+ lines << " Next:"
1667
+ resolution["next"].each { |step| lines << " #{step}" }
1668
+ end
1322
1669
  lines.join("\n")
1323
1670
  end
1324
1671
 
@@ -1326,14 +1673,17 @@ module Tina4
1326
1673
  # `actions_taken` list from the " Created <path>" lines the generators
1327
1674
  # emit. Machine callers get one clean JSON envelope on stdout; the
1328
1675
  # generator's own log stays discoverable via the returned list.
1329
- def run_generator_capturing_actions(gen_spec, name, flags)
1676
+ # `generator_kwargs` are forwarded verbatim to the generator method so
1677
+ # callers (e.g. `cmd_migrate_create`) can pass overrides like
1678
+ # `emit_test: false` without duplicating the envelope-emission path.
1679
+ def run_generator_capturing_actions(gen_spec, name, flags, **generator_kwargs)
1330
1680
  require "stringio"
1331
1681
  require "json"
1332
1682
  buffer = StringIO.new
1333
1683
  original = $stdout
1334
1684
  $stdout = buffer
1335
1685
  begin
1336
- send(gen_spec[:handler], name, flags)
1686
+ send(gen_spec[:handler], name, flags, **generator_kwargs)
1337
1687
  ensure
1338
1688
  $stdout = original
1339
1689
  end
@@ -1371,6 +1721,7 @@ module Tina4
1371
1721
  class #{name} < Tina4::ORM
1372
1722
  table_name "#{table}"
1373
1723
 
1724
+ # tina4:edit add fields beyond the default 'name'
1374
1725
  #{field_lines.join("\n")}
1375
1726
  end
1376
1727
  RUBY
@@ -1627,12 +1978,25 @@ module Tina4
1627
1978
 
1628
1979
  # ── Generator: migration ─────────────────────────────────────────────
1629
1980
 
1630
- def generate_migration(name, flags = {}, fields_override: nil, table_override: nil, emit_test: true)
1631
- now = Time.now
1981
+ def generate_migration(name, flags = {}, fields_override: nil, table_override: nil,
1982
+ emit_test: true, description: nil)
1983
+ # ADR-0063: `generate_timestamp` is the shared memoised `Time.now` for
1984
+ # this cmd_generate run; the resolution's `migration_path` uses the SAME
1985
+ # value, so envelope and disk agree.
1986
+ now = generate_timestamp
1632
1987
  timestamp = now.strftime("%Y%m%d%H%M%S")
1633
1988
  dir = "migrations"
1634
1989
  FileUtils.mkdir_p(dir)
1635
1990
 
1991
+ # `description` (when supplied by `cmd_migrate_create`) keeps the raw
1992
+ # human prose that appears in the file HEADER — the filename still uses
1993
+ # the snake_case `name`, so the two CLI paths write matching filenames
1994
+ # while the delegating path preserves the readable "add users table"
1995
+ # comment inside the file. Falls back to `name` when no description was
1996
+ # supplied (the `tina4ruby generate migration` path, where `name` is
1997
+ # already snake_case).
1998
+ header_text = description || name
1999
+
1636
2000
  # Determine table name
1637
2001
  if table_override
1638
2002
  table = table_override
@@ -1661,11 +2025,18 @@ module Tina4
1661
2025
  end
1662
2026
  col_lines << " created_at TEXT DEFAULT CURRENT_TIMESTAMP"
1663
2027
 
1664
- up_sql = "CREATE TABLE IF NOT EXISTS #{table} (\n#{col_lines.join(",\n")}\n);"
1665
- down_sql = "DROP TABLE IF EXISTS #{table};"
2028
+ # ADR-0063: `-- tina4:edit` marker for the SQL scanner (mirrors `# tina4:edit`
2029
+ # in Ruby files). The scanner regex accepts both comment prefixes.
2030
+ up_sql = "CREATE TABLE IF NOT EXISTS #{table} (\n" \
2031
+ " -- tina4:edit add columns beyond id + created_at\n" \
2032
+ "#{col_lines.join(",\n")}\n);"
2033
+ down_sql = "-- tina4:edit mirror the CREATE's added columns in the rollback\n" \
2034
+ "DROP TABLE IF EXISTS #{table};"
1666
2035
  else
1667
- up_sql = "-- Write your UP migration SQL here\n-- Example: ALTER TABLE #{table} ADD COLUMN new_col TEXT DEFAULT '';"
1668
- down_sql = "-- Write your DOWN rollback SQL here\n-- Example: ALTER TABLE #{table} DROP COLUMN new_col;"
2036
+ up_sql = "-- tina4:edit write your UP migration SQL here\n" \
2037
+ "-- Example: ALTER TABLE #{table} ADD COLUMN new_col TEXT DEFAULT '';"
2038
+ down_sql = "-- tina4:edit write your DOWN rollback SQL here\n" \
2039
+ "-- Example: ALTER TABLE #{table} DROP COLUMN new_col;"
1669
2040
  end
1670
2041
 
1671
2042
  # The main .sql holds ONLY the UP migration. The runner executes the WHOLE
@@ -1674,7 +2045,7 @@ module Tina4
1674
2045
  # rollback SQL lives solely in the sibling .down.sql. Matches the Python
1675
2046
  # master (tina4_python/cli/__init__.py).
1676
2047
  content = <<~SQL
1677
- -- Migration: #{name}
2048
+ -- Migration: #{header_text}
1678
2049
  -- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
1679
2050
 
1680
2051
  #{up_sql}
@@ -1687,7 +2058,7 @@ module Tina4
1687
2058
  down_filename = "#{timestamp}_#{name}.down.sql"
1688
2059
  down_path = File.join(dir, down_filename)
1689
2060
  down_content = <<~SQL
1690
- -- Rollback: #{name}
2061
+ -- Rollback: #{header_text}
1691
2062
  -- Created: #{now.strftime("%Y-%m-%d %H:%M:%S")}
1692
2063
 
1693
2064
  #{down_sql}
@@ -1727,12 +2098,14 @@ module Tina4
1727
2098
  # Runs before the route handler.
1728
2099
  # Return [request, response] to continue, or
1729
2100
  # return [request, response.json({ error: "Unauthorized" }, 401)] to block.
2101
+ # tina4:edit guard the request here
1730
2102
  Tina4::Log.info("#{name}: \#{request.method} \#{request.path}")
1731
2103
  [request, response]
1732
2104
  end
1733
2105
 
1734
2106
  def self.after_#{snake}(request, response)
1735
2107
  # Runs after the route handler.
2108
+ # tina4:edit post-process the response here
1736
2109
  [request, response]
1737
2110
  end
1738
2111
  end
@@ -1836,6 +2209,7 @@ module Tina4
1836
2209
  # Tests for #{name} CRUD operations
1837
2210
  RSpec.describe "#{model}" do
1838
2211
  before(:each) do
2212
+ # tina4:edit seed real fixtures for a real DB
1839
2213
  # Set up test fixtures
1840
2214
  end
1841
2215
 
@@ -1844,7 +2218,7 @@ module Tina4
1844
2218
  end
1845
2219
 
1846
2220
  it "lists #{snake}" do
1847
- # TODO: implement
2221
+ # tina4:edit replace the tautology with a real assertion
1848
2222
  expect(true).to be true
1849
2223
  end
1850
2224
 
@@ -1875,6 +2249,7 @@ module Tina4
1875
2249
  # Tests for #{name}
1876
2250
  RSpec.describe "#{class_name}" do
1877
2251
  before(:each) do
2252
+ # tina4:edit seed real fixtures for a real DB
1878
2253
  # Set up test fixtures
1879
2254
  end
1880
2255
 
@@ -1883,7 +2258,7 @@ module Tina4
1883
2258
  end
1884
2259
 
1885
2260
  it "works as expected" do
1886
- # TODO: replace with real tests
2261
+ # tina4:edit replace the tautology with a real assertion
1887
2262
  expect(true).to be true
1888
2263
  end
1889
2264
  end
@@ -1955,6 +2330,7 @@ module Tina4
1955
2330
  end
1956
2331
 
1957
2332
  content = <<~HTML
2333
+ {# tina4:edit restyle the form beyond the scaffolded defaults #}
1958
2334
  {% extends "base.twig" %}
1959
2335
  {% block title %}#{name} {% if item.id %}Edit{% else %}Create{% endif %}{% endblock %}
1960
2336
  {% block content %}
@@ -1994,6 +2370,7 @@ module Tina4
1994
2370
  td = cols.map { |c| "<td>{{ item.#{c} }}</td>" }.join("\n ")
1995
2371
 
1996
2372
  list_content = <<~HTML
2373
+ {# tina4:edit add sort / filter / pagination controls to the list #}
1997
2374
  {% extends "base.twig" %}
1998
2375
  {% block title %}#{name}s{% endblock %}
1999
2376
  {% block content %}
@@ -2044,6 +2421,7 @@ module Tina4
2044
2421
  end.join("\n")
2045
2422
 
2046
2423
  detail_content = <<~HTML
2424
+ {# tina4:edit extend the detail view with related records or actions #}
2047
2425
  {% extends "base.twig" %}
2048
2426
  {% block title %}#{name} Detail{% endblock %}
2049
2427
  {% block content %}
@@ -2087,6 +2465,7 @@ module Tina4
2087
2465
  # clears the router's auth_required).
2088
2466
  Tina4.post "/api/auth/register", auth: false do |request, response|
2089
2467
  # Register a new user
2468
+ # tina4:edit harden the register flow (rate limit / captcha / password rules)
2090
2469
  email = request.body["email"].to_s
2091
2470
  password = request.body["password"].to_s
2092
2471
 
@@ -2112,6 +2491,7 @@ module Tina4
2112
2491
  # PUBLIC: login mints the token — clear BOTH write gates (see register).
2113
2492
  Tina4.post "/api/auth/login", auth: false do |request, response|
2114
2493
  # Login with email and password
2494
+ # tina4:edit harden the login flow (rate limit / lockout after N failures)
2115
2495
  email = request.body["email"].to_s
2116
2496
  password = request.body["password"].to_s
2117
2497
 
@@ -3146,7 +3526,12 @@ module Tina4
3146
3526
  # KNOW the envelope shape before it invokes generate (see cmd_generate).
3147
3527
  # Bump `version` when the envelope shape changes; add a new envelope
3148
3528
  # name rather than mutating the existing one.
3149
- "resolution_contract" => { "version" => "1", "envelope" => "generate_v1" }
3529
+ # ADR-0063: v1.1 adds two additive keys to `resolution` — `edit_hints[]`
3530
+ # (grep of every `# tina4:edit <label>` / `-- tina4:edit <label>` line
3531
+ # in the generated files) and `next[]` (curated actionable steps). Every
3532
+ # v1 key is preserved untouched, so a v1 caller reading v1.1 output
3533
+ # keeps working.
3534
+ "resolution_contract" => { "version" => "1.1", "envelope" => "generate_v1_1" }
3150
3535
  }
3151
3536
  end
3152
3537
 
@@ -7,6 +7,8 @@ require "net/http"
7
7
  require "uri"
8
8
  require "fileutils"
9
9
  require "shellwords"
10
+ require "rbconfig" # RbConfig.ruby for scaffold_run's Open3.capture3 call to exe/tina4ruby
11
+ require "open3" # scaffold_run shells to exe/tina4ruby generate <kind> <name>
10
12
  require_relative "metrics"
11
13
 
12
14
  module Tina4
@@ -2294,38 +2296,53 @@ module Tina4
2294
2296
  ] }
2295
2297
  end
2296
2298
 
2299
+ # scaffold_run — shells to `exe/tina4ruby generate <kind> <name>` so the
2300
+ # dev-admin endpoint emits the ADR-0063 resolution envelope through the
2301
+ # SAME code path the CLI + MCP go through, instead of the old inline
2302
+ # File.write branches that skipped every marker + envelope. Mirrors PHP
2303
+ # (Tina4/DevAdmin.php:2506-2520, shell_exec 'php bin/tina4php generate ...')
2304
+ # and Node (packages/core/src/devAdmin.ts:handleScaffoldRun, execFileSync
2305
+ # 'npx tina4nodejs generate ...'). The captured stdout+stderr is returned
2306
+ # as `output`, matching PHP/Node's shape.
2307
+ #
2308
+ # ADR-0063: the four resolution-aware verbs (route / model / migration /
2309
+ # middleware) are the whitelist here — the same set the CLI declares in
2310
+ # cli.rb's `resolution_aware`. An unknown kind returns {ok:false,error}
2311
+ # instead of shelling to the CLI with an invalid subcommand.
2312
+ SCAFFOLD_ALLOWED_KINDS = %w[route model migration middleware].freeze
2313
+
2297
2314
  def scaffold_run(body)
2298
- kind = body["kind"].to_s
2315
+ require "open3"
2316
+
2317
+ kind = body["kind"].to_s.strip.downcase
2299
2318
  name = body["name"].to_s.strip
2319
+
2300
2320
  return { ok: false, error: "kind + name required" } if kind.empty? || name.empty?
2301
- project = Dir.pwd
2302
- case kind
2303
- when "route"
2304
- target = File.join(project, "src", "routes", "#{name}.rb")
2305
- FileUtils.mkdir_p(File.dirname(target))
2306
- File.write(target, "# #{name} routes\nTina4::Router.get(\"/api/#{name}\") do |req, res|\n res.call({ hello: \"#{name}\" })\nend\n") unless File.exist?(target)
2307
- { ok: true, created: target.sub("#{project}/", "") }
2308
- when "model"
2309
- target = File.join(project, "src", "orm", "#{name}.rb")
2310
- FileUtils.mkdir_p(File.dirname(target))
2311
- cls = name.to_s.split(/[_-]/).map(&:capitalize).join
2312
- File.write(target, "class #{cls} < Tina4::ORM\n integer_field :id, primary_key: true, auto_increment: true\n string_field :name\nend\n") unless File.exist?(target)
2313
- { ok: true, created: target.sub("#{project}/", "") }
2314
- when "migration"
2315
- ts = Time.now.strftime("%Y%m%d%H%M%S")
2316
- target = File.join(project, "migrations", "#{ts}_#{name}.sql")
2317
- FileUtils.mkdir_p(File.dirname(target))
2318
- File.write(target, "-- migration: #{name}\n")
2319
- { ok: true, created: target.sub("#{project}/", "") }
2320
- when "middleware"
2321
- target = File.join(project, "src", "app", "#{name}.rb")
2322
- FileUtils.mkdir_p(File.dirname(target))
2323
- cls = name.to_s.split(/[_-]/).map(&:capitalize).join
2324
- File.write(target, "class #{cls}\n def self.before_check(req, res); [req, res]; end\nend\n") unless File.exist?(target)
2325
- { ok: true, created: target.sub("#{project}/", "") }
2326
- else
2327
- { ok: false, error: "unknown kind: #{kind}" }
2321
+ unless SCAFFOLD_ALLOWED_KINDS.include?(kind)
2322
+ return { ok: false, error: "unknown kind: #{kind}" }
2323
+ end
2324
+ # Same name validation the PHP + Node dev-admin endpoints apply —
2325
+ # keeps a shell-metacharacter payload out of the generator arg.
2326
+ unless name =~ /\A[A-Za-z][\w]*\z/
2327
+ return { ok: false, error: "name must match [A-Za-z][A-Za-z0-9_]*" }
2328
2328
  end
2329
+
2330
+ project = Dir.pwd
2331
+ exe = File.join(project, "exe", "tina4ruby")
2332
+
2333
+ stdout, stderr, status = Open3.capture3(
2334
+ RbConfig.ruby, exe, "generate", kind, name,
2335
+ chdir: project
2336
+ )
2337
+
2338
+ ok = status.exitstatus == 0
2339
+ {
2340
+ ok: ok,
2341
+ kind: kind,
2342
+ name: name,
2343
+ output: stdout.to_s + stderr.to_s,
2344
+ error: (ok ? nil : (stderr.to_s.strip.empty? ? "generate exited #{status.exitstatus}" : stderr.to_s.strip))
2345
+ }.compact
2329
2346
  end
2330
2347
 
2331
2348
  # ── Live Docs (Live API RAG) ─────────────────────────────────
data/lib/tina4/mcp.rb CHANGED
@@ -1015,11 +1015,104 @@ module Tina4
1015
1015
  migration.respond_to?(:status) ? migration.status : { "info" => "Migration status not available" }
1016
1016
  }, "List pending and completed migrations")
1017
1017
 
1018
+ # migration_create — delegates to the SAME resolution-aware helper that
1019
+ # backs `tina4ruby migrate:create` + `tina4ruby generate migration`, so the
1020
+ # MCP tool emits the ADR-0063 `generate_v1_1` envelope (edit_hints[],
1021
+ # next[], `-- tina4:edit` markers baked into the UP+DOWN files) alongside
1022
+ # the pre-existing `{created}` contract. Duplicate-slug guard mirrors the
1023
+ # PHP + Python MCPs: if a migration file for the same slug already lives
1024
+ # under migrations/, refuse (the AI should edit the existing one instead
1025
+ # of spawning a second migration for the same schema change).
1018
1026
  server.register_tool("migration_create", lambda { |description:|
1019
- migration = Tina4::Migration.new(nil)
1020
- filename = migration.create(description)
1021
- { "created" => filename }
1022
- }, "Create a new migration file")
1027
+ require "fileutils"
1028
+ require_relative "cli"
1029
+
1030
+ desc = (description || "").to_s
1031
+ slug = desc.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
1032
+
1033
+ # Duplicate-slug guard — walks migrations/ and compares against the
1034
+ # slugified basename (stripping the leading 14-digit timestamp + the
1035
+ # .sql / .down.sql extension). Handles the "create_orders_table" vs
1036
+ # "create_orders" equivalence PHP + Python already recognise.
1037
+ mig_dir = File.join(Dir.pwd, "migrations")
1038
+ if slug != "" && File.directory?(mig_dir)
1039
+ existing = []
1040
+ Dir.glob(File.join(mig_dir, "*.sql")).sort.each do |path|
1041
+ base = File.basename(path)
1042
+ after = base.sub(/\A\d{14}_/, "").sub(/\.(down\.)?sql\z/, "")
1043
+ existing_slug = after.gsub(/[^a-z0-9]+/i, "_").downcase.gsub(/^_|_$/, "")
1044
+ if existing_slug == slug ||
1045
+ existing_slug == "#{slug}_table" ||
1046
+ slug == "#{existing_slug}_table"
1047
+ existing << path.sub("#{Dir.pwd}/", "")
1048
+ end
1049
+ end
1050
+ unless existing.empty?
1051
+ next {
1052
+ "ok" => false,
1053
+ "error" => "Migration for #{desc.inspect} already exists: #{existing.first}. " \
1054
+ "Edit it with file_patch / file_write instead of creating a duplicate.",
1055
+ "existing" => existing
1056
+ }
1057
+ end
1058
+ end
1059
+
1060
+ # Delegate through the SHARED resolution-aware helper (the same entry
1061
+ # point cmd_migrate_create uses since 1f09a31), so the envelope shape
1062
+ # here is byte-for-byte the same as `tina4ruby migrate:create <desc>
1063
+ # --json`. Human-mode output is captured and discarded — the MCP tool
1064
+ # returns structured JSON only.
1065
+ cli = Tina4::CLI.new
1066
+ # `reset_generate_timestamp!` and `run_resolution_aware_generator` are
1067
+ # private-by-declaration in cli.rb (everything below `private` at
1068
+ # cli.rb:175). They are the SHARED entry points cmd_generate +
1069
+ # cmd_migrate_create use, so calling them through #send here is the
1070
+ # deliberate re-use path — not a boundary crossing.
1071
+ cli.send(:reset_generate_timestamp!)
1072
+
1073
+ name = slug
1074
+
1075
+ require "stringio"
1076
+ old_stdout = $stdout
1077
+ old_stderr = $stderr
1078
+ stdout_buf = StringIO.new
1079
+ stderr_buf = StringIO.new
1080
+ envelope = nil
1081
+ begin
1082
+ $stdout = stdout_buf
1083
+ $stderr = stderr_buf
1084
+ # json_mode: true → run_resolution_aware_generator prints the
1085
+ # envelope to stdout (captured here); dry_run: false → the migration
1086
+ # files are really written under migrations/ in Dir.pwd.
1087
+ cli.send(
1088
+ :run_resolution_aware_generator,
1089
+ "migration", name, {},
1090
+ json_mode: true,
1091
+ dry_run: false,
1092
+ gen_spec: Tina4::CLI::GENERATORS["migration"],
1093
+ generator_kwargs: { emit_test: false, description: desc }
1094
+ )
1095
+ envelope = JSON.parse(stdout_buf.string)
1096
+ ensure
1097
+ $stdout = old_stdout
1098
+ $stderr = old_stderr
1099
+ end
1100
+
1101
+ # actions_taken carries "wrote migrations/<ts>_<name>.sql" lines; the
1102
+ # first .sql (never .down.sql) is the canonical `created` filename that
1103
+ # earlier callers of migration_create relied on.
1104
+ actions = envelope["actions_taken"] || []
1105
+ created = actions.map { |a| a.to_s.sub(/\Awrote\s+/, "") }
1106
+ .find { |p| p.end_with?(".sql") && !p.end_with?(".down.sql") }
1107
+ created ||= (envelope.dig("resolution", "file_path"))
1108
+
1109
+ {
1110
+ "ok" => true,
1111
+ "created" => created,
1112
+ "resolution" => envelope["resolution"],
1113
+ "actions_taken" => actions
1114
+ }
1115
+ }, "Create a new migration file (emits ADR-0063 resolution envelope)")
1023
1116
 
1024
1117
  server.register_tool("migration_run", lambda {
1025
1118
  db = Tina4.database
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.119"
4
+ VERSION = "3.13.121"
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.119
4
+ version: 3.13.121
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-26 00:00:00.000000000 Z
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack