tina4ruby 3.11.35 → 3.11.36

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.
data/lib/tina4/frond.rb CHANGED
@@ -548,6 +548,20 @@ module Tina4
548
548
  value = eval_expr(var_name, context)
549
549
  filters.each do |fname, args|
550
550
  next if fname == "raw" || fname == "safe"
551
+
552
+ # Filter + property-access chain: `first.groupSummary` — apply
553
+ # the filter, then traverse the path on the result using a
554
+ # synthetic context so eval_expr's dotted resolution does the
555
+ # work. Parity with tina4-python + tina4-php.
556
+ real_fname, tail_path = split_filter_name_and_path(fname)
557
+ if !tail_path.empty? && @filters[real_fname]
558
+ evaluated_args = args.map { |a| eval_filter_arg(a, context) }
559
+ value = @filters[real_fname].call(value, *evaluated_args)
560
+ value = eval_expr("__frond_filter_tmp.#{tail_path}",
561
+ { "__frond_filter_tmp" => value })
562
+ next
563
+ end
564
+
551
565
  fn = @filters[fname]
552
566
  if fn
553
567
  evaluated_args = args.map { |a| eval_filter_arg(a, context) }
@@ -611,6 +625,19 @@ module Tina4
611
625
  next
612
626
  end
613
627
 
628
+ # Filter + property-access chain: `first.groupSummary` — apply
629
+ # the filter, then traverse the path on the result. Done BEFORE
630
+ # the inline fast-path so cases like `items|first.name` work
631
+ # regardless of whether `first` is an inline filter too.
632
+ real_fname, tail_path = split_filter_name_and_path(fname)
633
+ if !tail_path.empty? && @filters[real_fname]
634
+ evaluated_args = args.map { |a| eval_filter_arg(a, context) }
635
+ value = @filters[real_fname].call(value, *evaluated_args)
636
+ value = eval_expr("__frond_filter_tmp.#{tail_path}",
637
+ { "__frond_filter_tmp" => value })
638
+ next
639
+ end
640
+
614
641
  # Inline common no-arg filters for speed (skip generic dispatch)
615
642
  if args.empty? && INLINE_FILTERS.include?(fname)
616
643
  value = case fname
@@ -756,6 +783,41 @@ module Tina4
756
783
  # Filter chain parser
757
784
  # -----------------------------------------------------------------------
758
785
 
786
+ # Split "first.groupSummary" into ["first", "groupSummary"] so a
787
+ # filter segment followed by property access — `{{ x | first.name }}`
788
+ # — applies the filter then traverses the path on the result.
789
+ # Returns [fname, ""] when no structural dot is present.
790
+ #
791
+ # The split point must sit outside parens/brackets/braces and quotes
792
+ # so filter args like `round(1.5)` or `date("Y.m.d")` don't false-
793
+ # trigger. Parity with tina4-python and tina4-php.
794
+ def split_filter_name_and_path(fname)
795
+ depth = 0
796
+ in_q = nil
797
+ i = 0
798
+ n = fname.length
799
+ while i < n
800
+ ch = fname[i]
801
+ if in_q
802
+ in_q = nil if ch == in_q && (i.zero? || fname[i - 1] != "\\")
803
+ i += 1
804
+ next
805
+ end
806
+ case ch
807
+ when '"', "'"
808
+ in_q = ch
809
+ when "(", "[", "{"
810
+ depth += 1
811
+ when ")", "]", "}"
812
+ depth -= 1
813
+ when "."
814
+ return [fname[0...i], fname[(i + 1)..]] if depth.zero?
815
+ end
816
+ i += 1
817
+ end
818
+ [fname, ""]
819
+ end
820
+
759
821
  def parse_filter_chain(expr)
760
822
  cached = @filter_chain_cache[expr]
761
823
  return cached if cached
data/lib/tina4/mcp.rb CHANGED
@@ -680,6 +680,196 @@ module Tina4
680
680
  end
681
681
  }, "Seed a table with fake data")
682
682
 
683
+ # ── File patch ────────────────────────────────────
684
+ server.register_tool("file_patch", lambda { |path:, old_string:, new_string:, count: 1|
685
+ p = safe_path.call(path)
686
+ return { "error" => "File not found: #{path}" } unless File.file?(p)
687
+ original = File.read(p, encoding: "utf-8")
688
+ occurrences = original.scan(old_string).size
689
+ return { "error" => "old_string not found in #{path}" } if occurrences.zero?
690
+ if occurrences != count.to_i
691
+ return { "error" => "old_string appears #{occurrences} times, expected #{count}. Expand old_string to make it unique, or set count explicitly." }
692
+ end
693
+ updated = original.sub(old_string, new_string)
694
+ # Ruby String#sub replaces first; if count > 1, do N replacements
695
+ if count.to_i > 1
696
+ updated = original.dup
697
+ count.to_i.times { updated.sub!(old_string, new_string) }
698
+ end
699
+ File.write(p, updated, encoding: "utf-8")
700
+ rel = p.sub("#{project_root}/", "")
701
+ Tina4::Plan.record_action("patched", rel) if defined?(Tina4::Plan)
702
+ { "patched" => rel, "replacements" => count.to_i, "bytes" => updated.bytesize }
703
+ }, "Targeted edit: replace old_string with new_string in a file")
704
+
705
+ # ── Docs tools ────────────────────────────────────
706
+ framework_doc_paths = lambda do
707
+ gem_root = File.expand_path("..", File.dirname(__FILE__))
708
+ candidates = [
709
+ File.join(gem_root, "..", "CLAUDE.md"),
710
+ File.join(gem_root, "..", "AGENTS.md"),
711
+ File.join(gem_root, "..", "CONVENTIONS.md"),
712
+ File.join(gem_root, "..", "README.md"),
713
+ File.join(Dir.pwd, "CLAUDE.md")
714
+ ]
715
+ candidates.map { |p| File.expand_path(p) }.uniq.select { |p| File.file?(p) }
716
+ end
717
+
718
+ server.register_tool("docs_list", lambda {
719
+ framework_doc_paths.call.map { |p| { "name" => File.basename(p), "bytes" => File.size(p) } }
720
+ }, "List framework documentation files")
721
+
722
+ server.register_tool("docs_search", lambda { |query:, limit: 5, context_lines: 4|
723
+ return { "error" => "query must be at least 2 characters" } if query.to_s.length < 2
724
+ needle = query.to_s.downcase
725
+ hits = []
726
+ framework_doc_paths.call.each do |p|
727
+ begin
728
+ lines = File.read(p, encoding: "utf-8", invalid: :replace, undef: :replace).split("\n")
729
+ rescue StandardError
730
+ next
731
+ end
732
+ lines.each_with_index do |line, i|
733
+ next unless line.downcase.include?(needle)
734
+ start_i = [0, i - context_lines.to_i].max
735
+ end_i = [lines.size, i + context_lines.to_i + 1].min
736
+ score = 1
737
+ score += 1 if line.include?(query.to_s)
738
+ score += 2 if line.lstrip.start_with?("#")
739
+ hits << {
740
+ "file" => File.basename(p),
741
+ "line" => i + 1,
742
+ "score" => score,
743
+ "snippet" => lines[start_i...end_i].join("\n")
744
+ }
745
+ end
746
+ end
747
+ hits.sort_by! { |h| -h["score"] }
748
+ hits.first([1, limit.to_i].max)
749
+ }, "Search Tina4 framework docs for a query string")
750
+
751
+ server.register_tool("docs_section", lambda { |file:, heading:|
752
+ match = framework_doc_paths.call.find { |p| File.basename(p) == file }
753
+ return { "error" => "Unknown doc file: #{file}. Try docs_list() first." } unless match
754
+ text = File.read(match, encoding: "utf-8", invalid: :replace, undef: :replace)
755
+ lines = text.split("\n")
756
+ heading_lc = heading.to_s.downcase.strip
757
+ start_i = -1
758
+ start_level = 0
759
+ lines.each_with_index do |line, i|
760
+ stripped = line.lstrip
761
+ next unless stripped.start_with?("#")
762
+ level = stripped.length - stripped.sub(/\A#+/, "").length
763
+ title = stripped[level..].to_s.strip.downcase
764
+ if title.include?(heading_lc)
765
+ start_i = i
766
+ start_level = level
767
+ break
768
+ end
769
+ end
770
+ return { "error" => "Heading '#{heading}' not found in #{file}" } if start_i < 0
771
+ end_i = lines.size
772
+ (start_i + 1).upto(lines.size - 1) do |j|
773
+ stripped = lines[j].lstrip
774
+ next unless stripped.start_with?("#")
775
+ level = stripped.length - stripped.sub(/\A#+/, "").length
776
+ if level <= start_level
777
+ end_i = j
778
+ break
779
+ end
780
+ end
781
+ { "file" => file, "heading" => lines[start_i].strip, "body" => lines[start_i...end_i].join("\n") }
782
+ }, "Return a full markdown section from a framework doc file")
783
+
784
+ # ── Git / deps / project ──────────────────────────
785
+ server.register_tool("git_status", lambda {
786
+ Tina4::DevAdmin.send(:git_status_payload)
787
+ }, "Show git branch, modified/untracked files, recent commits")
788
+
789
+ server.register_tool("deps_list", lambda {
790
+ gemfile = File.join(Dir.pwd, "Gemfile")
791
+ return { "error" => "No Gemfile at project root" } unless File.file?(gemfile)
792
+ deps = File.read(gemfile).scan(/^\s*gem\s+["']([^"']+)["']/).flatten
793
+ { "name" => File.basename(Dir.pwd), "dependencies" => deps }
794
+ }, "List this project's declared Ruby dependencies")
795
+
796
+ server.register_tool("project_overview", lambda {
797
+ { "system" => { "framework" => "tina4-ruby", "version" => (defined?(Tina4::VERSION) ? Tina4::VERSION : "unknown"), "ruby" => RUBY_DESCRIPTION, "cwd" => project_root } }
798
+ }, "One-shot snapshot: system + project info")
799
+
800
+ # ── Project index ─────────────────────────────────
801
+ server.register_tool("index_rebuild", lambda {
802
+ Tina4::ProjectIndex.refresh
803
+ }, "Refresh the persistent project index (lazy, mtime-based)")
804
+
805
+ server.register_tool("index_search", lambda { |query:, limit: 20|
806
+ Tina4::ProjectIndex.search(query, limit.to_i)
807
+ }, "Find files by path, symbol, route, or summary")
808
+
809
+ server.register_tool("index_file", lambda { |path:|
810
+ Tina4::ProjectIndex.file_entry(path)
811
+ }, "Full index entry for one file")
812
+
813
+ server.register_tool("index_overview", lambda {
814
+ Tina4::ProjectIndex.overview
815
+ }, "Project shape: files by language, routes, models, recent edits")
816
+
817
+ # ── Plan management ───────────────────────────────
818
+ server.register_tool("plan_current", lambda {
819
+ Tina4::Plan.current
820
+ }, "The active plan: title, steps (done/not), next step, progress")
821
+
822
+ server.register_tool("plan_list", lambda {
823
+ Tina4::Plan.list_plans
824
+ }, "All plans in plan/ with progress and which one is active")
825
+
826
+ server.register_tool("plan_create", lambda { |title:, goal: "", steps: nil, make_current: true|
827
+ Tina4::Plan.create(title, goal: goal, steps: steps, make_current: make_current)
828
+ }, "Create a new markdown plan in plan/ and make it active")
829
+
830
+ server.register_tool("plan_switch_to", lambda { |name:|
831
+ Tina4::Plan.set_current(name)
832
+ }, "Make a different plan the active one")
833
+
834
+ server.register_tool("plan_complete_step", lambda { |index:|
835
+ Tina4::Plan.complete_step(index.to_i)
836
+ }, "Tick a step as done (call the moment the step finishes)")
837
+
838
+ server.register_tool("plan_add_step", lambda { |text:|
839
+ Tina4::Plan.add_step(text)
840
+ }, "Append a new unchecked step to the current plan")
841
+
842
+ server.register_tool("plan_note", lambda { |text:|
843
+ Tina4::Plan.append_note(text)
844
+ }, "Append a timestamped note/breadcrumb to the current plan")
845
+
846
+ server.register_tool("plan_archive", lambda { |name: ""|
847
+ Tina4::Plan.archive(name)
848
+ }, "Move a finished plan to plan/done/")
849
+
850
+ server.register_tool("plan_read", lambda { |name:|
851
+ Tina4::Plan.read(name)
852
+ }, "Full structured view of any plan by filename")
853
+
854
+ server.register_tool("plan_flesh", lambda { |name: "", prompt: ""|
855
+ Tina4::Plan.flesh(name, prompt)
856
+ }, "Auto-generate concrete build steps via AI and append them to an existing plan")
857
+
858
+ # ── Live API Docs (Live API RAG) ──────────────────
859
+ server.register_tool("api_search", lambda { |query:, k: 5, source: "all", include_private: false|
860
+ Tina4::Docs.cached(project_root).search(
861
+ query.to_s, k: k.to_i, source: source.to_s, include_private: include_private == true || include_private.to_s == "true"
862
+ )
863
+ }, "Search the live API index (framework + user code) for ranked hits")
864
+
865
+ server.register_tool("api_class", lambda { |name:|
866
+ Tina4::Docs.cached(project_root).class_spec(name.to_s)
867
+ }, "Full class reflection (methods, file, line) from the live API index")
868
+
869
+ server.register_tool("api_method", lambda { |class_name:, name:|
870
+ Tina4::Docs.cached(project_root).method_spec(class_name.to_s, name.to_s)
871
+ }, "Single method spec (signature, summary, file, line) from the live API index")
872
+
683
873
  # ── System Tools ──────────────────────────────────
684
874
  server.register_tool("system_info", lambda {
685
875
  {
data/lib/tina4/orm.rb CHANGED
@@ -52,15 +52,31 @@ module Tina4
52
52
  @field_mapping = map
53
53
  end
54
54
 
55
- # Auto-map flag (no-op in Ruby since snake_case is native)
55
+ # Auto-map flag defaults to TRUE for cross-framework parity (Python's
56
+ # ORM has auto_map=True by default). The instance variable is treated
57
+ # as "unset" when nil; only an explicit `false` disables it.
56
58
  def auto_map
57
- @auto_map || false
59
+ defined?(@auto_map) && !@auto_map.nil? ? @auto_map : true
58
60
  end
59
61
 
60
62
  def auto_map=(val)
61
63
  @auto_map = val
62
64
  end
63
65
 
66
+ # auto_crud flag — when set to true, the class registers itself with
67
+ # Tina4::AutoCrud which auto-generates REST endpoints from the model.
68
+ # Defaults to false. Cross-framework parity with Python's autoCrud.
69
+ def auto_crud
70
+ defined?(@auto_crud) && !@auto_crud.nil? ? @auto_crud : false
71
+ end
72
+
73
+ def auto_crud=(val)
74
+ @auto_crud = val
75
+ if val && defined?(::Tina4::AutoCrud)
76
+ ::Tina4::AutoCrud.models << self unless ::Tina4::AutoCrud.models.include?(self)
77
+ end
78
+ end
79
+
64
80
  # Relationship definitions
65
81
  def relationship_definitions
66
82
  @relationship_definitions ||= {}
@@ -343,8 +359,10 @@ module Tina4
343
359
  instance
344
360
  end
345
361
 
346
- private
347
-
362
+ # find_by_id is PUBLIC — cross-framework parity with Python's
363
+ # MyModel.find_by_id(pk_value) and PHP's User::find($id). Spec at
364
+ # spec/orm_spec.rb:78 verifies public access. find_by_filter stays
365
+ # public for the same reason; both are part of the documented API.
348
366
  def find_by_id(id)
349
367
  pk = primary_key_field || :id
350
368
  sql = "SELECT * FROM #{table_name} WHERE #{pk} = ?"
@@ -474,15 +492,30 @@ module Tina4
474
492
  errors
475
493
  end
476
494
 
477
- def load(id = nil)
478
- pk = self.class.primary_key_field || :id
479
- id ||= __send__(pk)
480
- return false unless id
495
+ # load populate this instance from the database.
496
+ #
497
+ # Three forms (parity with Python's model.load(sql, params, include)):
498
+ # user.load # reload by primary key from instance
499
+ # user.load(123) # load by primary key value
500
+ # user.load("email = ?", ["a@b.c"]) # load by filter SQL + params (selectOne)
501
+ #
502
+ # Returns true on hit, false on miss. Always clears the relationship cache.
503
+ def load(arg = nil, params = nil)
481
504
  @relationship_cache = {} # Clear relationship cache on reload
505
+ pk = self.class.primary_key_field || :id
482
506
 
483
- result = self.class.db.fetch_one(
484
- "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
485
- )
507
+ if arg.is_a?(String)
508
+ # Filter-SQL form: user.load("email = ?", ["a@b.c"])
509
+ sql = "SELECT * FROM #{self.class.table_name} WHERE #{arg} LIMIT 1"
510
+ result = self.class.db.fetch_one(sql, params || [])
511
+ else
512
+ # Primary-key form: user.load OR user.load(123)
513
+ id = arg || __send__(pk)
514
+ return false unless id
515
+ result = self.class.db.fetch_one(
516
+ "SELECT * FROM #{self.class.table_name} WHERE #{pk} = ?", [id]
517
+ )
518
+ end
486
519
  return false unless result
487
520
 
488
521
  mapping_reverse = self.class.field_mapping.invert
@@ -505,7 +538,10 @@ module Tina4
505
538
 
506
539
  # Convert to hash using Ruby attribute names.
507
540
  # Optionally include relationships via the include keyword.
508
- def to_h(include: nil)
541
+ # case: "camel" converts snake_case keys to camelCase (parity with
542
+ # Python's to_dict(case='camel')). Default keeps native snake_case.
543
+ def to_h(include: nil, case: nil)
544
+ key_case = binding.local_variable_get(:case) # :case is a reserved word
509
545
  hash = {}
510
546
  self.class.field_definitions.each_key do |name|
511
547
  hash[name] = __send__(name)
@@ -534,6 +570,15 @@ module Tina4
534
570
  end
535
571
  end
536
572
 
573
+ if key_case == "camel" || key_case == :camel
574
+ # snake_case → camelCase: split on _, capitalize all but the first
575
+ hash = hash.each_with_object({}) do |(k, v), out|
576
+ parts = k.to_s.split("_")
577
+ camel = parts[0] + parts[1..].map(&:capitalize).join
578
+ out[camel.to_sym] = v
579
+ end
580
+ end
581
+
537
582
  hash
538
583
  end
539
584