contextizer 0.1.1

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.
@@ -0,0 +1,2538 @@
1
+ # Contextizer Report
2
+
3
+ ## Meta Context
4
+ - **Project:** contextizer
5
+ - **Type:** Gem
6
+ - **Version:** 0.1.0
7
+ - **Extracted At:** 2025-09-01T19:23:39Z
8
+
9
+ ### Command
10
+ ```bash
11
+ contextizer extract --git-url https://github.com/alexander-s-f/contextizer
12
+ ```
13
+
14
+ ### Git Info
15
+ - **Branch:** `master`
16
+ - **Commit:** `a8b51c67`
17
+
18
+ ### Key Ruby Dependencies
19
+ Not found.
20
+
21
+ ### Key JS Dependencies
22
+ Not found.
23
+
24
+ ---
25
+ ### File Structure
26
+ <details>
27
+ <summary>Click to view file tree</summary>
28
+
29
+ ```text
30
+ contextizer/
31
+ ├── CHANGELOG.md
32
+ ├── CODE_OF_CONDUCT.md
33
+ ├── README.md
34
+ ├── config
35
+ │ └── default.yml
36
+ ├── contextizer.gemspec
37
+ └── lib
38
+ ├── contextizer
39
+ │ ├── analyzer.rb
40
+ │ ├── analyzers
41
+ │ │ ├── base.rb
42
+ │ │ ├── java_script_analyzer.rb
43
+ │ │ ├── python_analyzer.rb
44
+ │ │ └── ruby_analyzer.rb
45
+ │ ├── cli.rb
46
+ │ ├── collector.rb
47
+ │ ├── configuration.rb
48
+ │ ├── context.rb
49
+ │ ├── providers
50
+ │ │ ├── base
51
+ │ │ │ ├── file_system.rb
52
+ │ │ │ ├── git.rb
53
+ │ │ │ └── project_name.rb
54
+ │ │ ├── base_provider.rb
55
+ │ │ ├── javascript
56
+ │ │ │ └── packages.rb
57
+ │ │ └── ruby
58
+ │ │ ├── gems.rb
59
+ │ │ └── project_info.rb
60
+ │ ├── remote_repo_handler.rb
61
+ │ ├── renderers
62
+ │ │ ├── base.rb
63
+ │ │ └── markdown.rb
64
+ │ ├── version.rb
65
+ │ └── writer.rb
66
+ └── contextizer.rb
67
+ ```
68
+
69
+ </details>
70
+
71
+ ---
72
+ #### File: `lib/contextizer/analyzer.rb`
73
+ ```ruby
74
+ # frozen_string_literal: true
75
+
76
+ module Contextizer
77
+ class Analyzer
78
+ SPECIALISTS = Analyzers.constants.map do |const|
79
+ Analyzers.const_get(const)
80
+ end.select { |const| const.is_a?(Class) && const < Analyzers::Base }
81
+
82
+ def self.call(target_path:)
83
+ new(target_path: target_path).analyze
84
+ end
85
+
86
+ def initialize(target_path:)
87
+ @target_path = target_path
88
+ end
89
+
90
+ def analyze
91
+ results = SPECIALISTS.map do |specialist_class|
92
+ specialist_class.call(target_path: @target_path)
93
+ end.compact
94
+
95
+ return { language: :unknown, framework: nil, scores: {} } if results.empty?
96
+
97
+ best_result = results.max_by { |result| result[:score] }
98
+
99
+ {
100
+ language: best_result[:language],
101
+ framework: best_result[:framework],
102
+ scores: results.map { |r| [r[:language], r[:score]] }.to_h
103
+ }
104
+ end
105
+ end
106
+ end
107
+
108
+ ```
109
+
110
+ ---
111
+ #### File: `lib/contextizer/analyzers/base.rb`
112
+ ```ruby
113
+ # frozen_string_literal: true
114
+
115
+ module Contextizer
116
+ module Analyzers
117
+ class Base
118
+ def self.call(target_path:)
119
+ new(target_path: target_path).analyze
120
+ end
121
+
122
+ def initialize(target_path:)
123
+ @target_path = Pathname.new(target_path)
124
+ @score = 0
125
+ end
126
+
127
+ def analyze
128
+ raise NotImplementedError, "#{self.class.name} must implement #analyze"
129
+ end
130
+
131
+ protected
132
+
133
+ def check_signal(signal)
134
+ path = @target_path.join(signal[:path])
135
+ case signal[:type]
136
+ when :file
137
+ @target_path.glob(signal[:path]).any?
138
+ when :dir
139
+ path.directory?
140
+ else
141
+ false
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ ```
149
+
150
+ ---
151
+ #### File: `lib/contextizer/analyzers/java_script_analyzer.rb`
152
+ ```ruby
153
+ # frozen_string_literal: true
154
+
155
+ module Contextizer
156
+ module Analyzers
157
+ class JavaScriptAnalyzer < Base
158
+ LANGUAGE = :javascript
159
+
160
+ SIGNALS = [
161
+ { type: :file, path: "package.json", weight: 5 },
162
+ { type: :dir, path: "node_modules", weight: 10 },
163
+ { type: :file, path: "webpack.config.js", weight: 5 },
164
+ { type: :file, path: "vite.config.js", weight: 5 }
165
+ ].freeze
166
+
167
+ FRAMEWORK_SIGNALS = {}.freeze
168
+
169
+ def analyze
170
+ SIGNALS.each { |signal| @score += signal[:weight] if check_signal(signal) }
171
+
172
+ return nil if @score.zero?
173
+
174
+ {
175
+ language: LANGUAGE,
176
+ framework: nil,
177
+ score: @score
178
+ }
179
+ end
180
+ end
181
+ end
182
+ end
183
+
184
+ ```
185
+
186
+ ---
187
+ #### File: `lib/contextizer/analyzers/python_analyzer.rb`
188
+ ```ruby
189
+ # frozen_string_literal: true
190
+
191
+ module Contextizer
192
+ module Analyzers
193
+ class PythonAnalyzer < Base
194
+ LANGUAGE = :python
195
+
196
+ SIGNALS = [
197
+ { type: :file, path: "requirements.txt", weight: 10 },
198
+ { type: :file, path: "pyproject.toml", weight: 10 },
199
+ ].freeze
200
+
201
+ FRAMEWORK_SIGNALS = {
202
+ # rails: [{ type: :file, path: "bin/rails", weight: 15 }]
203
+ }.freeze
204
+
205
+ def analyze
206
+ SIGNALS.each { |signal| @score += signal[:weight] if check_signal(signal) }
207
+
208
+ return nil if @score.zero?
209
+
210
+ {
211
+ language: LANGUAGE,
212
+ framework: detect_framework,
213
+ score: @score
214
+ }
215
+ end
216
+
217
+ private
218
+
219
+ def detect_framework
220
+ (FRAMEWORK_SIGNALS || {}).each do |fw, signals|
221
+ return fw if signals.any? { |signal| check_signal(signal) }
222
+ end
223
+ nil
224
+ end
225
+ end
226
+ end
227
+ end
228
+
229
+ ```
230
+
231
+ ---
232
+ #### File: `lib/contextizer/analyzers/ruby_analyzer.rb`
233
+ ```ruby
234
+ # frozen_string_literal: true
235
+
236
+ module Contextizer
237
+ module Analyzers
238
+ class RubyAnalyzer < Base
239
+ LANGUAGE = :ruby
240
+
241
+ SIGNALS = [
242
+ { type: :file, path: "Gemfile", weight: 10 },
243
+ { type: :file, path: "*.gemspec", weight: 20 },
244
+ { type: :dir, path: "app/controllers", weight: 5 }
245
+ ].freeze
246
+
247
+ FRAMEWORK_SIGNALS = {
248
+ rails: [{ type: :file, path: "bin/rails", weight: 15 }]
249
+ }.freeze
250
+
251
+ def analyze
252
+ SIGNALS.each { |signal| @score += signal[:weight] if check_signal(signal) }
253
+
254
+ return nil if @score.zero?
255
+
256
+ {
257
+ language: LANGUAGE,
258
+ framework: detect_framework,
259
+ score: @score
260
+ }
261
+ end
262
+
263
+ private
264
+
265
+ def detect_framework
266
+ (FRAMEWORK_SIGNALS || {}).each do |fw, signals|
267
+ return fw if signals.any? { |signal| check_signal(signal) }
268
+ end
269
+ nil
270
+ end
271
+ end
272
+ end
273
+ end
274
+
275
+ ```
276
+
277
+ ---
278
+ #### File: `lib/contextizer/cli.rb`
279
+ ```ruby
280
+ # frozen_string_literal: true
281
+
282
+ require "thor"
283
+ require "yaml"
284
+
285
+ module Contextizer
286
+ class CLI < Thor
287
+ desc "extract [TARGET_PATH]", "Extracts project context into a single file."
288
+ option :git_url,
289
+ type: :string,
290
+ desc: "URL of a remote git repository to analyze instead of a local path."
291
+ option :output,
292
+ aliases: "-o",
293
+ type: :string,
294
+ desc: "Output file path (overrides config)."
295
+ option :format,
296
+ aliases: "-f",
297
+ type: :string,
298
+ desc: "Output format (e.g., markdown, json)."
299
+
300
+ RENDERER_MAPPING = {
301
+ "markdown" => Renderers::Markdown
302
+ }.freeze
303
+
304
+ def extract(target_path = ".")
305
+ if options[:git_url]
306
+ RemoteRepoHandler.handle(options[:git_url]) do |remote_path|
307
+ run_extraction(remote_path)
308
+ end
309
+ else
310
+ run_extraction(target_path)
311
+ end
312
+ end
313
+
314
+ private
315
+
316
+ def run_extraction(path)
317
+ cli_options = options.transform_keys(&:to_s).compact
318
+ config = Configuration.load(cli_options)
319
+
320
+ context = Collector.call(config: config, target_path: path)
321
+
322
+ renderer = RENDERER_MAPPING[config.format]
323
+ raise Error, "Unsupported format: '#{config.format}'" unless renderer
324
+
325
+ rendered_output = renderer.call(context: context)
326
+
327
+ destination_path = resolve_output_path(config.output, context)
328
+ Writer.call(content: rendered_output, destination: destination_path)
329
+
330
+ puts "\nContextizer: Extraction complete! ✨"
331
+ end
332
+
333
+ def resolve_output_path(path_template, context)
334
+ timestamp = Time.now.strftime("%Y-%m-%d_%H%M")
335
+ project_name = context.metadata.dig(:project, :type) == "Gem" ? context.project_name : "project"
336
+
337
+
338
+ path_template
339
+ .gsub("{profile}", "default")
340
+ .gsub("{timestamp}", timestamp)
341
+ .gsub("{project_name}", project_name)
342
+ end
343
+ end
344
+ end
345
+ ```
346
+
347
+ ---
348
+ #### File: `lib/contextizer/collector.rb`
349
+ ```ruby
350
+ # frozen_string_literal: true
351
+
352
+ module Contextizer
353
+ class Collector
354
+ BASE_PROVIDERS = [
355
+ Providers::Base::ProjectName,
356
+ Providers::Base::Git,
357
+ Providers::Base::FileSystem
358
+ ].freeze
359
+
360
+ LANGUAGE_MODULES = {
361
+ ruby: Providers::Ruby,
362
+ javascript: Providers::JavaScript
363
+ }.freeze
364
+
365
+ def self.call(config:, target_path:)
366
+ new(config: config, target_path: target_path).call
367
+ end
368
+
369
+ def initialize(config:, target_path:)
370
+ @config = config
371
+ @target_path = target_path
372
+ @context = Context.new(target_path: target_path)
373
+ end
374
+
375
+ def call
376
+ stack = Analyzer.call(target_path: @target_path)
377
+ @context.metadata[:stack] = stack
378
+
379
+ BASE_PROVIDERS.each do |provider_class|
380
+ provider_class.call(context: @context, config: @config)
381
+ end
382
+
383
+ language_module = LANGUAGE_MODULES[stack[:language]]
384
+ run_language_providers(language_module) if language_module
385
+
386
+ puts "Collector: Collection complete. Found #{@context.files.count} files."
387
+ @context
388
+ end
389
+
390
+ private
391
+
392
+ def run_language_providers(language_module)
393
+ puts "Collector: Running '#{language_module.name.split('::').last}' providers..."
394
+ language_module.constants.each do |const_name|
395
+ provider_class = language_module.const_get(const_name)
396
+ if provider_class.is_a?(Class) && provider_class < Providers::BaseProvider
397
+ provider_class.call(context: @context, config: @config)
398
+ end
399
+ end
400
+ end
401
+ end
402
+ end
403
+
404
+ ```
405
+
406
+ ---
407
+ #### File: `lib/contextizer/configuration.rb`
408
+ ```ruby
409
+ # frozen_string_literal: true
410
+
411
+ require "yaml"
412
+ require "pathname"
413
+
414
+ module Contextizer
415
+ class Configuration
416
+ # CLI options > Project Config > Default Config
417
+ def self.load(cli_options = {})
418
+ default_config_path = Pathname.new(__dir__).join("../../config/default.yml")
419
+ default_config = YAML.load_file(default_config_path)
420
+
421
+ project_config_path = find_project_config
422
+ project_config = project_config_path ? YAML.load_file(project_config_path) : {}
423
+
424
+ merged_config = deep_merge(default_config, project_config)
425
+ merged_config = deep_merge(merged_config, cli_options)
426
+
427
+ new(merged_config)
428
+ end
429
+
430
+ def initialize(options)
431
+ @options = options
432
+ end
433
+
434
+ def method_missing(name, *args, &block)
435
+ key = name.to_s
436
+ if @options.key?(key)
437
+ @options[key]
438
+ else
439
+ super
440
+ end
441
+ end
442
+
443
+ def respond_to_missing?(name, include_private = false)
444
+ @options.key?(name.to_s) || super
445
+ end
446
+
447
+ def self.find_project_config(path = Dir.pwd)
448
+ path = Pathname.new(path)
449
+ loop do
450
+ config_file = path.join(".contextizer.yml")
451
+ return config_file if config_file.exist?
452
+ break if path.root?
453
+
454
+ path = path.parent
455
+ end
456
+ nil
457
+ end
458
+
459
+ def self.deep_merge(hash1, hash2)
460
+ hash1.merge(hash2) do |key, old_val, new_val|
461
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
462
+ deep_merge(old_val, new_val)
463
+ else
464
+ new_val
465
+ end
466
+ end
467
+ end
468
+ end
469
+ end
470
+
471
+ ```
472
+
473
+ ---
474
+ #### File: `lib/contextizer/context.rb`
475
+ ```ruby
476
+ # frozen_string_literal: true
477
+
478
+ module Contextizer
479
+ # A value object that holds all the collected information about a project.
480
+ # This object is the result of the Collector phase and the input for the Renderer phase.
481
+ Context = Struct.new(
482
+ :project_name,
483
+ :target_path,
484
+ :timestamp,
485
+ :metadata, # Hash for data from providers like Git, Gems, etc.
486
+ :files, # Array of file objects { path:, content: }
487
+ keyword_init: true
488
+ ) do
489
+ def initialize(*)
490
+ super
491
+ self.metadata ||= {}
492
+ self.files ||= []
493
+ self.timestamp ||= Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
494
+ end
495
+ end
496
+ end
497
+
498
+ ```
499
+
500
+ ---
501
+ #### File: `lib/contextizer/providers/base/file_system.rb`
502
+ ```ruby
503
+ # frozen_string_literal: true
504
+
505
+ require "pathname"
506
+
507
+ module Contextizer
508
+ module Providers
509
+ module Base
510
+ class FileSystem < BaseProvider
511
+ def self.call(context:, config:)
512
+ new(context: context, config: config).collect_files
513
+ end
514
+
515
+ def initialize(context:, config:)
516
+ @context = context
517
+ @config = config.settings["filesystem"]
518
+ @target_path = Pathname.new(context.target_path)
519
+ end
520
+
521
+ def collect_files
522
+ file_paths = find_matching_files
523
+
524
+ file_paths.each do |path|
525
+ content = read_file_content(path)
526
+ next unless content
527
+
528
+ @context.files << {
529
+ path: path.relative_path_from(@target_path).to_s,
530
+ content: content
531
+ }
532
+ end
533
+ end
534
+
535
+ private
536
+
537
+ def find_matching_files
538
+ patterns = @config["components"].values.flatten.map do |pattern|
539
+ (@target_path / pattern).to_s
540
+ end
541
+
542
+ all_files = Dir.glob(patterns)
543
+
544
+ exclude_patterns = @config["exclude"].map do |pattern|
545
+ (@target_path / pattern).to_s
546
+ end
547
+
548
+ all_files.reject do |file|
549
+ exclude_patterns.any? { |pattern| File.fnmatch?(pattern, file) }
550
+ end.map { |file| Pathname.new(file) }
551
+ end
552
+
553
+ def read_file_content(path)
554
+ path.read
555
+ rescue Errno::ENOENT, IOError => e
556
+ warn "Warning: Could not read file #{path}: #{e.message}"
557
+ nil
558
+ end
559
+ end
560
+ end
561
+ end
562
+ end
563
+
564
+ ```
565
+
566
+ ---
567
+ #### File: `lib/contextizer/providers/base/git.rb`
568
+ ```ruby
569
+ # frozen_string_literal: true
570
+
571
+ module Contextizer
572
+ module Providers
573
+ module Base
574
+ class Git < BaseProvider
575
+ def self.call(context:, config:)
576
+ context.metadata[:git] = fetch_git_info(context.target_path)
577
+ @config = config
578
+ end
579
+
580
+ def self.fetch_git_info(path)
581
+ Dir.chdir(path) do
582
+ {
583
+ branch: `git rev-parse --abbrev-ref HEAD`.strip,
584
+ commit: `git rev-parse HEAD`.strip[0, 8]
585
+ }
586
+ end
587
+ rescue StandardError
588
+ { branch: "N/A", commit: "N/A" }
589
+ end
590
+ end
591
+ end
592
+ end
593
+ end
594
+
595
+ ```
596
+
597
+ ---
598
+ #### File: `lib/contextizer/providers/base/project_name.rb`
599
+ ```ruby
600
+ # frozen_string_literal: true
601
+
602
+ module Contextizer
603
+ module Providers
604
+ module Base
605
+ class ProjectName < BaseProvider
606
+ def self.call(context:, config:)
607
+ context.project_name = detect_project_name(context.target_path)
608
+ end
609
+
610
+ private
611
+
612
+ def self.detect_project_name(path)
613
+ git_name = name_from_git_remote(path)
614
+ return git_name if git_name
615
+
616
+ File.basename(path)
617
+ end
618
+
619
+ def self.name_from_git_remote(path)
620
+ return nil unless Dir.exist?(File.join(path, ".git"))
621
+
622
+ Dir.chdir(path) do
623
+ remote_url = `git remote get-url origin`.strip
624
+ remote_url.split("/").last.sub(/\.git$/, "")
625
+ end
626
+ rescue StandardError
627
+ nil
628
+ end
629
+ end
630
+ end
631
+ end
632
+ end
633
+
634
+ ```
635
+
636
+ ---
637
+ #### File: `lib/contextizer/providers/base_provider.rb`
638
+ ```ruby
639
+ # frozen_string_literal: true
640
+
641
+ module Contextizer
642
+ module Providers
643
+ class BaseProvider
644
+ # @param context [Contextizer::Context] The context object to be populated.
645
+ # @param config [Contextizer::Configuration] The overall configuration.
646
+ def self.call(context:, config:)
647
+ raise NotImplementedError, "#{self.name} must implement the .call method"
648
+ end
649
+ end
650
+ end
651
+ end
652
+
653
+ ```
654
+
655
+ ---
656
+ #### File: `lib/contextizer/providers/javascript/packages.rb`
657
+ ```ruby
658
+ # frozen_string_literal: true
659
+ require "json"
660
+
661
+ module Contextizer
662
+ module Providers
663
+ module JavaScript
664
+ class Packages < BaseProvider
665
+ def self.call(context:, config:)
666
+ package_json_path = File.join(context.target_path, "package.json")
667
+ return unless File.exist?(package_json_path)
668
+
669
+ context.metadata[:packages] = parse_packages(package_json_path)
670
+ end
671
+
672
+ private
673
+
674
+ def self.parse_packages(path)
675
+ begin
676
+ file = File.read(path)
677
+ data = JSON.parse(file)
678
+ {
679
+ dependencies: data["dependencies"] || {},
680
+ dev_dependencies: data["devDependencies"] || {}
681
+ }
682
+ rescue JSON::ParserError => e
683
+ puts "Warning: Could not parse package.json: #{e.message}"
684
+ {}
685
+ end
686
+ end
687
+ end
688
+ end
689
+ end
690
+ end
691
+
692
+ ```
693
+
694
+ ---
695
+ #### File: `lib/contextizer/providers/ruby/gems.rb`
696
+ ```ruby
697
+ # frozen_string_literal: true
698
+
699
+ module Contextizer
700
+ module Providers
701
+ module Ruby
702
+ class Gems < BaseProvider
703
+ def self.call(context:, config:)
704
+ key_gems = config.settings.dig("gems", "key_gems")
705
+ return if key_gems.nil? || key_gems.empty?
706
+
707
+ gemfile_lock = File.join(context.target_path, "Gemfile.lock")
708
+ return unless File.exist?(gemfile_lock)
709
+
710
+ context.metadata[:gems] = parse_gemfile_lock(gemfile_lock, key_gems)
711
+ end
712
+
713
+ def self.parse_gemfile_lock(path, key_gems)
714
+ found_gems = {}
715
+ content = File.read(path)
716
+ key_gems.each do |gem_name|
717
+ match = content.match(/^\s{4}#{gem_name}\s\((.+?)\)/)
718
+ found_gems[gem_name] = match[1] if match
719
+ end
720
+ found_gems
721
+ end
722
+ end
723
+ end
724
+ end
725
+ end
726
+
727
+ ```
728
+
729
+ ---
730
+ #### File: `lib/contextizer/providers/ruby/project_info.rb`
731
+ ```ruby
732
+ # frozen_string_literal: true
733
+
734
+ module Contextizer
735
+ module Providers
736
+ module Ruby
737
+ class ProjectInfo < BaseProvider
738
+ def self.call(context:, config:)
739
+ project_info = detect_project_info(context.target_path)
740
+ context.metadata[:project] = project_info
741
+ end
742
+
743
+
744
+ def self.detect_project_info(path)
745
+ if Dir.glob(File.join(path, "*.gemspec")).any?
746
+ return { type: "Gem", version: detect_gem_version(path) }
747
+ end
748
+
749
+ if File.exist?(File.join(path, "bin", "rails"))
750
+ return { type: "Rails", version: detect_rails_version(path) }
751
+ end
752
+
753
+ { type: "Directory", version: "N/A" }
754
+ end
755
+
756
+ def self.detect_gem_version(path)
757
+ version_file = Dir.glob(File.join(path, "lib", "**", "version.rb")).first
758
+ return "N/A" unless version_file
759
+
760
+ content = File.read(version_file)
761
+ match = content.match(/VERSION\s*=\s*["'](.+?)["']/)
762
+ match ? match[1] : "N/A"
763
+ end
764
+
765
+ def self.detect_rails_version(path)
766
+ gemfile_lock = File.join(path, "Gemfile.lock")
767
+ return "N/A" unless File.exist?(gemfile_lock)
768
+
769
+ content = File.read(gemfile_lock)
770
+ match = content.match(/^\s{4}rails\s\((.+?)\)/)
771
+ match ? match[1] : "N/A"
772
+ end
773
+ end
774
+ end
775
+ end
776
+ end
777
+
778
+ ```
779
+
780
+ ---
781
+ #### File: `lib/contextizer/remote_repo_handler.rb`
782
+ ```ruby
783
+ # frozen_string_literal: true
784
+
785
+ require "tmpdir"
786
+ require "fileutils"
787
+
788
+ module Contextizer
789
+ class RemoteRepoHandler
790
+ def self.handle(url)
791
+ Dir.mktmpdir("contextizer-clone-") do |temp_path|
792
+ puts "Cloning #{url} into temporary directory..."
793
+
794
+ success = system("git clone --depth 1 #{url} #{temp_path}")
795
+
796
+ if success
797
+ puts "Cloning successful."
798
+ yield(temp_path)
799
+ else
800
+ puts "Error: Failed to clone repository."
801
+ end
802
+ end
803
+ end
804
+ end
805
+ end
806
+
807
+ ```
808
+
809
+ ---
810
+ #### File: `lib/contextizer/renderers/base.rb`
811
+ ```ruby
812
+ # frozen_string_literal: true
813
+
814
+ module Contextizer
815
+ module Renderers
816
+ class Base
817
+ # @param context [Contextizer::Context] The context object to be rendered.
818
+ def self.call(context:)
819
+ raise NotImplementedError, "#{self.name} must implement the .call method"
820
+ end
821
+ end
822
+ end
823
+ end
824
+
825
+ ```
826
+
827
+ ---
828
+ #### File: `lib/contextizer/renderers/markdown.rb`
829
+ ```ruby
830
+ # frozen_string_literal: true
831
+
832
+ module Contextizer
833
+ module Renderers
834
+ class Markdown < Base
835
+ def self.call(context:)
836
+ new(context: context).render
837
+ end
838
+
839
+ def initialize(context:)
840
+ @context = context
841
+ end
842
+
843
+ def render
844
+ [
845
+ render_header,
846
+ render_file_tree,
847
+ render_files
848
+ ].join("\n---\n")
849
+ end
850
+
851
+ private
852
+
853
+ def render_header
854
+ project_info = @context.metadata[:project] || {}
855
+ git_info = @context.metadata[:git] || {}
856
+ gem_info = (@context.metadata[:gems] || {}).map { |n, v| "- **#{n}:** #{v}" }.join("\n")
857
+
858
+ packages = @context.metadata[:packages] || {}
859
+ pkg_info = (packages[:dependencies] || {}).map { |n, v| "- **#{n}:** #{v}" }.join("\n")
860
+
861
+ <<~HEADER
862
+ # Contextizer Report
863
+
864
+ ## Meta Context
865
+ - **Project:** #{@context.project_name}
866
+ - **Type:** #{project_info[:type]}
867
+ - **Version:** #{project_info[:version]}
868
+ - **Extracted At:** #{@context.timestamp}
869
+
870
+ ### Git Info
871
+ - **Branch:** `#{git_info[:branch]}`
872
+ - **Commit:** `#{git_info[:commit]}`
873
+
874
+ ### Key Ruby Dependencies
875
+ #{gem_info.empty? ? "Not found." : gem_info}
876
+
877
+ ### Key JS Dependencies
878
+ #{pkg_info.empty? ? "Not found." : pkg_info}
879
+ HEADER
880
+ end
881
+
882
+ def render_file_tree
883
+ paths = @context.files.map { |f| f[:path] }
884
+ tree_hash = build_path_hash(paths)
885
+ tree_string = format_tree_node(tree_hash).join("\n")
886
+
887
+ <<~TREE
888
+ ### File Structure
889
+ <details>
890
+ <summary>Click to view file tree</summary>
891
+
892
+ ```text
893
+ #{@context.project_name}/
894
+ #{tree_string}
895
+ ```
896
+
897
+ </details>
898
+ TREE
899
+ end
900
+
901
+ def build_path_hash(paths)
902
+ paths.each_with_object({}) do |path, hash|
903
+ path.split("/").reduce(hash) do |level, part|
904
+ level[part] ||= {}
905
+ end
906
+ end
907
+ end
908
+
909
+ def format_tree_node(node, prefix = "")
910
+ output = []
911
+ children = node.keys.sort
912
+ children.each_with_index do |key, index|
913
+ is_last = (index == children.length - 1)
914
+ connector = is_last ? "└── " : "├── "
915
+ output << "#{prefix}#{connector}#{key}"
916
+
917
+ if node[key].any?
918
+ new_prefix = prefix + (is_last ? " " : "│ ")
919
+ output.concat(format_tree_node(node[key], new_prefix))
920
+ end
921
+ end
922
+ output
923
+ end
924
+
925
+ def render_files
926
+ @context.files.map do |file|
927
+ lang = File.extname(file[:path]).delete(".")
928
+ lang = "ruby" if lang == "rb"
929
+ lang = "ruby" if lang == "gemspec"
930
+ lang = "javascript" if lang == "js"
931
+
932
+ <<~FILE_BLOCK
933
+ #### File: `#{file[:path]}`
934
+ ```#{lang}
935
+ #{file[:content]}
936
+ ```
937
+ FILE_BLOCK
938
+ end.join("\n---\n")
939
+ end
940
+ end
941
+ end
942
+ end
943
+
944
+ ```
945
+
946
+ ---
947
+ #### File: `lib/contextizer/version.rb`
948
+ ```ruby
949
+ # frozen_string_literal: true
950
+
951
+ module Contextizer
952
+ VERSION = "0.1.0"
953
+ end
954
+
955
+ ```
956
+
957
+ ---
958
+ #### File: `lib/contextizer/writer.rb`
959
+ ```ruby
960
+ # frozen_string_literal: true
961
+
962
+ require "fileutils"
963
+
964
+ module Contextizer
965
+ class Writer
966
+ def self.call(content:, destination:)
967
+ path = Pathname.new(destination)
968
+ puts "[Contextizer] Writer: Saving report to #{path}..."
969
+
970
+ FileUtils.mkdir_p(path.dirname)
971
+ File.write(path, content)
972
+
973
+ puts "[Contextizer] Writer: Report saved successfully."
974
+ end
975
+ end
976
+ end
977
+
978
+ ```
979
+
980
+ ---
981
+ #### File: `lib/contextizer.rb`
982
+ ```ruby
983
+ # frozen_string_literal: true
984
+
985
+ require "zeitwerk"
986
+ loader = Zeitwerk::Loader.for_gem
987
+ loader.inflector.inflect(
988
+ "cli" => "CLI",
989
+ "javascript" => "JavaScript",
990
+ )
991
+ loader.setup
992
+
993
+ require_relative "contextizer/version"
994
+
995
+ module Contextizer
996
+ class Error < StandardError; end
997
+ # ...
998
+ end
999
+
1000
+ loader.eager_load
1001
+
1002
+ ```
1003
+
1004
+ ---
1005
+ #### File: `config/default.yml`
1006
+ ```yml
1007
+ # config/default.yml
1008
+ # Gem's default configuration. DO NOT EDIT.
1009
+ # Create a .contextizer.yml in your project to override.
1010
+
1011
+ # Default output path.
1012
+ # Placeholders: {profile}, {timestamp}, {project_name}
1013
+ #output: "tmp/contextizer/{profile}_{timestamp}.md"
1014
+ output: "{project_name}_{timestamp}.md"
1015
+
1016
+ # Default rendering format.
1017
+ format: "markdown"
1018
+
1019
+ # Provider settings.
1020
+ providers:
1021
+ git: true
1022
+ filesystem: true
1023
+ gems: true
1024
+ project_info: true
1025
+ # Framework-specific providers are disabled by default.
1026
+ # They will be auto-enabled if the environment is detected.
1027
+ rails_routes: "auto"
1028
+ rails_schema: "auto"
1029
+
1030
+ # Settings for specific providers.
1031
+ settings:
1032
+ gems:
1033
+ key_gems:
1034
+ - rails
1035
+ - devise
1036
+ - sidekiq
1037
+ - rspec-rails
1038
+ - pg
1039
+ filesystem:
1040
+ # Default component definitions for a typical Ruby/Rails project.
1041
+ components:
1042
+ models: "app/models/**/*.rb"
1043
+ views: "app/views/**/*.{erb,haml,slim,arb}"
1044
+ controllers: "app/controllers/**/*.rb"
1045
+ services: "app/services/**/*.rb"
1046
+ lib: "lib/**/*.rb"
1047
+ config: "config/**/*.{rb,yml}"
1048
+ gem: # For gem development
1049
+ - "*.gemspec"
1050
+ - "lib/**/*.rb"
1051
+ - "README.md"
1052
+ - "CHANGELOG.md"
1053
+ documentation: "*.md"
1054
+ # Files/directories to exclude globally.
1055
+ exclude:
1056
+ - "tmp/**/*"
1057
+ - "log/**/*"
1058
+ - "node_modules/**/*"
1059
+ - ".git/**/*"
1060
+ ```
1061
+
1062
+ ---
1063
+ #### File: `contextizer.gemspec`
1064
+ ```ruby
1065
+ # frozen_string_literal: true
1066
+
1067
+ require_relative "lib/contextizer/version"
1068
+
1069
+ Gem::Specification.new do |spec|
1070
+ spec.name = "contextizer"
1071
+ spec.version = Contextizer::VERSION
1072
+ spec.authors = ["Alexander"]
1073
+ spec.email = ["alexander.s.fokin@gmail.com"]
1074
+
1075
+ spec.summary = "A tool to extract and package project context."
1076
+ spec.description = "Contextizer is a tool to extract and package project context."
1077
+ spec.homepage = "https://github.com/alexander-s-f/contextizer"
1078
+ spec.license = "MIT"
1079
+ spec.required_ruby_version = ">= 3.1.0"
1080
+
1081
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host"
1082
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
1083
+ spec.metadata["allowed_push_host"] = "http://mygemserver.com"
1084
+
1085
+ spec.metadata["homepage_uri"] = spec.homepage
1086
+ spec.metadata["source_code_uri"] = spec.homepage
1087
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
1088
+
1089
+ # Specify which files should be added to the gem when it is released.
1090
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
1091
+ gemspec = File.basename(__FILE__)
1092
+ spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
1093
+ ls.readlines("\x0", chomp: true).reject do |f|
1094
+ (f == gemspec) ||
1095
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile])
1096
+ end
1097
+ end
1098
+ spec.bindir = "exe"
1099
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
1100
+ spec.require_paths = ["lib"]
1101
+
1102
+ spec.add_development_dependency "bundler"
1103
+ spec.add_dependency "thor", "~> 1.3"
1104
+ spec.add_dependency "zeitwerk", "~> 2.6"
1105
+ end
1106
+
1107
+ ```
1108
+
1109
+ ---
1110
+ #### File: `lib/contextizer/analyzer.rb`
1111
+ ```ruby
1112
+ # frozen_string_literal: true
1113
+
1114
+ module Contextizer
1115
+ class Analyzer
1116
+ SPECIALISTS = Analyzers.constants.map do |const|
1117
+ Analyzers.const_get(const)
1118
+ end.select { |const| const.is_a?(Class) && const < Analyzers::Base }
1119
+
1120
+ def self.call(target_path:)
1121
+ new(target_path: target_path).analyze
1122
+ end
1123
+
1124
+ def initialize(target_path:)
1125
+ @target_path = target_path
1126
+ end
1127
+
1128
+ def analyze
1129
+ results = SPECIALISTS.map do |specialist_class|
1130
+ specialist_class.call(target_path: @target_path)
1131
+ end.compact
1132
+
1133
+ return { language: :unknown, framework: nil, scores: {} } if results.empty?
1134
+
1135
+ best_result = results.max_by { |result| result[:score] }
1136
+
1137
+ {
1138
+ language: best_result[:language],
1139
+ framework: best_result[:framework],
1140
+ scores: results.map { |r| [r[:language], r[:score]] }.to_h
1141
+ }
1142
+ end
1143
+ end
1144
+ end
1145
+
1146
+ ```
1147
+
1148
+ ---
1149
+ #### File: `lib/contextizer/analyzers/base.rb`
1150
+ ```ruby
1151
+ # frozen_string_literal: true
1152
+
1153
+ module Contextizer
1154
+ module Analyzers
1155
+ class Base
1156
+ def self.call(target_path:)
1157
+ new(target_path: target_path).analyze
1158
+ end
1159
+
1160
+ def initialize(target_path:)
1161
+ @target_path = Pathname.new(target_path)
1162
+ @score = 0
1163
+ end
1164
+
1165
+ def analyze
1166
+ raise NotImplementedError, "#{self.class.name} must implement #analyze"
1167
+ end
1168
+
1169
+ protected
1170
+
1171
+ def check_signal(signal)
1172
+ path = @target_path.join(signal[:path])
1173
+ case signal[:type]
1174
+ when :file
1175
+ @target_path.glob(signal[:path]).any?
1176
+ when :dir
1177
+ path.directory?
1178
+ else
1179
+ false
1180
+ end
1181
+ end
1182
+ end
1183
+ end
1184
+ end
1185
+
1186
+ ```
1187
+
1188
+ ---
1189
+ #### File: `lib/contextizer/analyzers/java_script_analyzer.rb`
1190
+ ```ruby
1191
+ # frozen_string_literal: true
1192
+
1193
+ module Contextizer
1194
+ module Analyzers
1195
+ class JavaScriptAnalyzer < Base
1196
+ LANGUAGE = :javascript
1197
+
1198
+ SIGNALS = [
1199
+ { type: :file, path: "package.json", weight: 5 },
1200
+ { type: :dir, path: "node_modules", weight: 10 },
1201
+ { type: :file, path: "webpack.config.js", weight: 5 },
1202
+ { type: :file, path: "vite.config.js", weight: 5 }
1203
+ ].freeze
1204
+
1205
+ FRAMEWORK_SIGNALS = {}.freeze
1206
+
1207
+ def analyze
1208
+ SIGNALS.each { |signal| @score += signal[:weight] if check_signal(signal) }
1209
+
1210
+ return nil if @score.zero?
1211
+
1212
+ {
1213
+ language: LANGUAGE,
1214
+ framework: nil,
1215
+ score: @score
1216
+ }
1217
+ end
1218
+ end
1219
+ end
1220
+ end
1221
+
1222
+ ```
1223
+
1224
+ ---
1225
+ #### File: `lib/contextizer/analyzers/python_analyzer.rb`
1226
+ ```ruby
1227
+ # frozen_string_literal: true
1228
+
1229
+ module Contextizer
1230
+ module Analyzers
1231
+ class PythonAnalyzer < Base
1232
+ LANGUAGE = :python
1233
+
1234
+ SIGNALS = [
1235
+ { type: :file, path: "requirements.txt", weight: 10 },
1236
+ { type: :file, path: "pyproject.toml", weight: 10 },
1237
+ ].freeze
1238
+
1239
+ FRAMEWORK_SIGNALS = {
1240
+ # rails: [{ type: :file, path: "bin/rails", weight: 15 }]
1241
+ }.freeze
1242
+
1243
+ def analyze
1244
+ SIGNALS.each { |signal| @score += signal[:weight] if check_signal(signal) }
1245
+
1246
+ return nil if @score.zero?
1247
+
1248
+ {
1249
+ language: LANGUAGE,
1250
+ framework: detect_framework,
1251
+ score: @score
1252
+ }
1253
+ end
1254
+
1255
+ private
1256
+
1257
+ def detect_framework
1258
+ (FRAMEWORK_SIGNALS || {}).each do |fw, signals|
1259
+ return fw if signals.any? { |signal| check_signal(signal) }
1260
+ end
1261
+ nil
1262
+ end
1263
+ end
1264
+ end
1265
+ end
1266
+
1267
+ ```
1268
+
1269
+ ---
1270
+ #### File: `lib/contextizer/analyzers/ruby_analyzer.rb`
1271
+ ```ruby
1272
+ # frozen_string_literal: true
1273
+
1274
+ module Contextizer
1275
+ module Analyzers
1276
+ class RubyAnalyzer < Base
1277
+ LANGUAGE = :ruby
1278
+
1279
+ SIGNALS = [
1280
+ { type: :file, path: "Gemfile", weight: 10 },
1281
+ { type: :file, path: "*.gemspec", weight: 20 },
1282
+ { type: :dir, path: "app/controllers", weight: 5 }
1283
+ ].freeze
1284
+
1285
+ FRAMEWORK_SIGNALS = {
1286
+ rails: [{ type: :file, path: "bin/rails", weight: 15 }]
1287
+ }.freeze
1288
+
1289
+ def analyze
1290
+ SIGNALS.each { |signal| @score += signal[:weight] if check_signal(signal) }
1291
+
1292
+ return nil if @score.zero?
1293
+
1294
+ {
1295
+ language: LANGUAGE,
1296
+ framework: detect_framework,
1297
+ score: @score
1298
+ }
1299
+ end
1300
+
1301
+ private
1302
+
1303
+ def detect_framework
1304
+ (FRAMEWORK_SIGNALS || {}).each do |fw, signals|
1305
+ return fw if signals.any? { |signal| check_signal(signal) }
1306
+ end
1307
+ nil
1308
+ end
1309
+ end
1310
+ end
1311
+ end
1312
+
1313
+ ```
1314
+
1315
+ ---
1316
+ #### File: `lib/contextizer/cli.rb`
1317
+ ```ruby
1318
+ # frozen_string_literal: true
1319
+
1320
+ require "thor"
1321
+ require "yaml"
1322
+
1323
+ module Contextizer
1324
+ class CLI < Thor
1325
+ desc "extract [TARGET_PATH]", "Extracts project context into a single file."
1326
+ option :git_url,
1327
+ type: :string,
1328
+ desc: "URL of a remote git repository to analyze instead of a local path."
1329
+ option :output,
1330
+ aliases: "-o",
1331
+ type: :string,
1332
+ desc: "Output file path (overrides config)."
1333
+ option :format,
1334
+ aliases: "-f",
1335
+ type: :string,
1336
+ desc: "Output format (e.g., markdown, json)."
1337
+
1338
+ RENDERER_MAPPING = {
1339
+ "markdown" => Renderers::Markdown
1340
+ }.freeze
1341
+
1342
+ def extract(target_path = ".")
1343
+ if options[:git_url]
1344
+ RemoteRepoHandler.handle(options[:git_url]) do |remote_path|
1345
+ run_extraction(remote_path)
1346
+ end
1347
+ else
1348
+ run_extraction(target_path)
1349
+ end
1350
+ end
1351
+
1352
+ private
1353
+
1354
+ def run_extraction(path)
1355
+ cli_options = options.transform_keys(&:to_s).compact
1356
+ config = Configuration.load(cli_options)
1357
+
1358
+ context = Collector.call(config: config, target_path: path)
1359
+
1360
+ renderer = RENDERER_MAPPING[config.format]
1361
+ raise Error, "Unsupported format: '#{config.format}'" unless renderer
1362
+
1363
+ rendered_output = renderer.call(context: context)
1364
+
1365
+ destination_path = resolve_output_path(config.output, context)
1366
+ Writer.call(content: rendered_output, destination: destination_path)
1367
+
1368
+ puts "\nContextizer: Extraction complete! ✨"
1369
+ end
1370
+
1371
+ def resolve_output_path(path_template, context)
1372
+ timestamp = Time.now.strftime("%Y-%m-%d_%H%M")
1373
+ project_name = context.metadata.dig(:project, :type) == "Gem" ? context.project_name : "project"
1374
+
1375
+
1376
+ path_template
1377
+ .gsub("{profile}", "default")
1378
+ .gsub("{timestamp}", timestamp)
1379
+ .gsub("{project_name}", project_name)
1380
+ end
1381
+ end
1382
+ end
1383
+ ```
1384
+
1385
+ ---
1386
+ #### File: `lib/contextizer/collector.rb`
1387
+ ```ruby
1388
+ # frozen_string_literal: true
1389
+
1390
+ module Contextizer
1391
+ class Collector
1392
+ BASE_PROVIDERS = [
1393
+ Providers::Base::ProjectName,
1394
+ Providers::Base::Git,
1395
+ Providers::Base::FileSystem
1396
+ ].freeze
1397
+
1398
+ LANGUAGE_MODULES = {
1399
+ ruby: Providers::Ruby,
1400
+ javascript: Providers::JavaScript
1401
+ }.freeze
1402
+
1403
+ def self.call(config:, target_path:)
1404
+ new(config: config, target_path: target_path).call
1405
+ end
1406
+
1407
+ def initialize(config:, target_path:)
1408
+ @config = config
1409
+ @target_path = target_path
1410
+ @context = Context.new(target_path: target_path)
1411
+ end
1412
+
1413
+ def call
1414
+ stack = Analyzer.call(target_path: @target_path)
1415
+ @context.metadata[:stack] = stack
1416
+
1417
+ BASE_PROVIDERS.each do |provider_class|
1418
+ provider_class.call(context: @context, config: @config)
1419
+ end
1420
+
1421
+ language_module = LANGUAGE_MODULES[stack[:language]]
1422
+ run_language_providers(language_module) if language_module
1423
+
1424
+ puts "Collector: Collection complete. Found #{@context.files.count} files."
1425
+ @context
1426
+ end
1427
+
1428
+ private
1429
+
1430
+ def run_language_providers(language_module)
1431
+ puts "Collector: Running '#{language_module.name.split('::').last}' providers..."
1432
+ language_module.constants.each do |const_name|
1433
+ provider_class = language_module.const_get(const_name)
1434
+ if provider_class.is_a?(Class) && provider_class < Providers::BaseProvider
1435
+ provider_class.call(context: @context, config: @config)
1436
+ end
1437
+ end
1438
+ end
1439
+ end
1440
+ end
1441
+
1442
+ ```
1443
+
1444
+ ---
1445
+ #### File: `lib/contextizer/configuration.rb`
1446
+ ```ruby
1447
+ # frozen_string_literal: true
1448
+
1449
+ require "yaml"
1450
+ require "pathname"
1451
+
1452
+ module Contextizer
1453
+ class Configuration
1454
+ # CLI options > Project Config > Default Config
1455
+ def self.load(cli_options = {})
1456
+ default_config_path = Pathname.new(__dir__).join("../../config/default.yml")
1457
+ default_config = YAML.load_file(default_config_path)
1458
+
1459
+ project_config_path = find_project_config
1460
+ project_config = project_config_path ? YAML.load_file(project_config_path) : {}
1461
+
1462
+ merged_config = deep_merge(default_config, project_config)
1463
+ merged_config = deep_merge(merged_config, cli_options)
1464
+
1465
+ new(merged_config)
1466
+ end
1467
+
1468
+ def initialize(options)
1469
+ @options = options
1470
+ end
1471
+
1472
+ def method_missing(name, *args, &block)
1473
+ key = name.to_s
1474
+ if @options.key?(key)
1475
+ @options[key]
1476
+ else
1477
+ super
1478
+ end
1479
+ end
1480
+
1481
+ def respond_to_missing?(name, include_private = false)
1482
+ @options.key?(name.to_s) || super
1483
+ end
1484
+
1485
+ def self.find_project_config(path = Dir.pwd)
1486
+ path = Pathname.new(path)
1487
+ loop do
1488
+ config_file = path.join(".contextizer.yml")
1489
+ return config_file if config_file.exist?
1490
+ break if path.root?
1491
+
1492
+ path = path.parent
1493
+ end
1494
+ nil
1495
+ end
1496
+
1497
+ def self.deep_merge(hash1, hash2)
1498
+ hash1.merge(hash2) do |key, old_val, new_val|
1499
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
1500
+ deep_merge(old_val, new_val)
1501
+ else
1502
+ new_val
1503
+ end
1504
+ end
1505
+ end
1506
+ end
1507
+ end
1508
+
1509
+ ```
1510
+
1511
+ ---
1512
+ #### File: `lib/contextizer/context.rb`
1513
+ ```ruby
1514
+ # frozen_string_literal: true
1515
+
1516
+ module Contextizer
1517
+ # A value object that holds all the collected information about a project.
1518
+ # This object is the result of the Collector phase and the input for the Renderer phase.
1519
+ Context = Struct.new(
1520
+ :project_name,
1521
+ :target_path,
1522
+ :timestamp,
1523
+ :metadata, # Hash for data from providers like Git, Gems, etc.
1524
+ :files, # Array of file objects { path:, content: }
1525
+ keyword_init: true
1526
+ ) do
1527
+ def initialize(*)
1528
+ super
1529
+ self.metadata ||= {}
1530
+ self.files ||= []
1531
+ self.timestamp ||= Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
1532
+ end
1533
+ end
1534
+ end
1535
+
1536
+ ```
1537
+
1538
+ ---
1539
+ #### File: `lib/contextizer/providers/base/file_system.rb`
1540
+ ```ruby
1541
+ # frozen_string_literal: true
1542
+
1543
+ require "pathname"
1544
+
1545
+ module Contextizer
1546
+ module Providers
1547
+ module Base
1548
+ class FileSystem < BaseProvider
1549
+ def self.call(context:, config:)
1550
+ new(context: context, config: config).collect_files
1551
+ end
1552
+
1553
+ def initialize(context:, config:)
1554
+ @context = context
1555
+ @config = config.settings["filesystem"]
1556
+ @target_path = Pathname.new(context.target_path)
1557
+ end
1558
+
1559
+ def collect_files
1560
+ file_paths = find_matching_files
1561
+
1562
+ file_paths.each do |path|
1563
+ content = read_file_content(path)
1564
+ next unless content
1565
+
1566
+ @context.files << {
1567
+ path: path.relative_path_from(@target_path).to_s,
1568
+ content: content
1569
+ }
1570
+ end
1571
+ end
1572
+
1573
+ private
1574
+
1575
+ def find_matching_files
1576
+ patterns = @config["components"].values.flatten.map do |pattern|
1577
+ (@target_path / pattern).to_s
1578
+ end
1579
+
1580
+ all_files = Dir.glob(patterns)
1581
+
1582
+ exclude_patterns = @config["exclude"].map do |pattern|
1583
+ (@target_path / pattern).to_s
1584
+ end
1585
+
1586
+ all_files.reject do |file|
1587
+ exclude_patterns.any? { |pattern| File.fnmatch?(pattern, file) }
1588
+ end.map { |file| Pathname.new(file) }
1589
+ end
1590
+
1591
+ def read_file_content(path)
1592
+ path.read
1593
+ rescue Errno::ENOENT, IOError => e
1594
+ warn "Warning: Could not read file #{path}: #{e.message}"
1595
+ nil
1596
+ end
1597
+ end
1598
+ end
1599
+ end
1600
+ end
1601
+
1602
+ ```
1603
+
1604
+ ---
1605
+ #### File: `lib/contextizer/providers/base/git.rb`
1606
+ ```ruby
1607
+ # frozen_string_literal: true
1608
+
1609
+ module Contextizer
1610
+ module Providers
1611
+ module Base
1612
+ class Git < BaseProvider
1613
+ def self.call(context:, config:)
1614
+ context.metadata[:git] = fetch_git_info(context.target_path)
1615
+ @config = config
1616
+ end
1617
+
1618
+ def self.fetch_git_info(path)
1619
+ Dir.chdir(path) do
1620
+ {
1621
+ branch: `git rev-parse --abbrev-ref HEAD`.strip,
1622
+ commit: `git rev-parse HEAD`.strip[0, 8]
1623
+ }
1624
+ end
1625
+ rescue StandardError
1626
+ { branch: "N/A", commit: "N/A" }
1627
+ end
1628
+ end
1629
+ end
1630
+ end
1631
+ end
1632
+
1633
+ ```
1634
+
1635
+ ---
1636
+ #### File: `lib/contextizer/providers/base/project_name.rb`
1637
+ ```ruby
1638
+ # frozen_string_literal: true
1639
+
1640
+ module Contextizer
1641
+ module Providers
1642
+ module Base
1643
+ class ProjectName < BaseProvider
1644
+ def self.call(context:, config:)
1645
+ context.project_name = detect_project_name(context.target_path)
1646
+ end
1647
+
1648
+ private
1649
+
1650
+ def self.detect_project_name(path)
1651
+ git_name = name_from_git_remote(path)
1652
+ return git_name if git_name
1653
+
1654
+ File.basename(path)
1655
+ end
1656
+
1657
+ def self.name_from_git_remote(path)
1658
+ return nil unless Dir.exist?(File.join(path, ".git"))
1659
+
1660
+ Dir.chdir(path) do
1661
+ remote_url = `git remote get-url origin`.strip
1662
+ remote_url.split("/").last.sub(/\.git$/, "")
1663
+ end
1664
+ rescue StandardError
1665
+ nil
1666
+ end
1667
+ end
1668
+ end
1669
+ end
1670
+ end
1671
+
1672
+ ```
1673
+
1674
+ ---
1675
+ #### File: `lib/contextizer/providers/base_provider.rb`
1676
+ ```ruby
1677
+ # frozen_string_literal: true
1678
+
1679
+ module Contextizer
1680
+ module Providers
1681
+ class BaseProvider
1682
+ # @param context [Contextizer::Context] The context object to be populated.
1683
+ # @param config [Contextizer::Configuration] The overall configuration.
1684
+ def self.call(context:, config:)
1685
+ raise NotImplementedError, "#{self.name} must implement the .call method"
1686
+ end
1687
+ end
1688
+ end
1689
+ end
1690
+
1691
+ ```
1692
+
1693
+ ---
1694
+ #### File: `lib/contextizer/providers/javascript/packages.rb`
1695
+ ```ruby
1696
+ # frozen_string_literal: true
1697
+ require "json"
1698
+
1699
+ module Contextizer
1700
+ module Providers
1701
+ module JavaScript
1702
+ class Packages < BaseProvider
1703
+ def self.call(context:, config:)
1704
+ package_json_path = File.join(context.target_path, "package.json")
1705
+ return unless File.exist?(package_json_path)
1706
+
1707
+ context.metadata[:packages] = parse_packages(package_json_path)
1708
+ end
1709
+
1710
+ private
1711
+
1712
+ def self.parse_packages(path)
1713
+ begin
1714
+ file = File.read(path)
1715
+ data = JSON.parse(file)
1716
+ {
1717
+ dependencies: data["dependencies"] || {},
1718
+ dev_dependencies: data["devDependencies"] || {}
1719
+ }
1720
+ rescue JSON::ParserError => e
1721
+ puts "Warning: Could not parse package.json: #{e.message}"
1722
+ {}
1723
+ end
1724
+ end
1725
+ end
1726
+ end
1727
+ end
1728
+ end
1729
+
1730
+ ```
1731
+
1732
+ ---
1733
+ #### File: `lib/contextizer/providers/ruby/gems.rb`
1734
+ ```ruby
1735
+ # frozen_string_literal: true
1736
+
1737
+ module Contextizer
1738
+ module Providers
1739
+ module Ruby
1740
+ class Gems < BaseProvider
1741
+ def self.call(context:, config:)
1742
+ key_gems = config.settings.dig("gems", "key_gems")
1743
+ return if key_gems.nil? || key_gems.empty?
1744
+
1745
+ gemfile_lock = File.join(context.target_path, "Gemfile.lock")
1746
+ return unless File.exist?(gemfile_lock)
1747
+
1748
+ context.metadata[:gems] = parse_gemfile_lock(gemfile_lock, key_gems)
1749
+ end
1750
+
1751
+ def self.parse_gemfile_lock(path, key_gems)
1752
+ found_gems = {}
1753
+ content = File.read(path)
1754
+ key_gems.each do |gem_name|
1755
+ match = content.match(/^\s{4}#{gem_name}\s\((.+?)\)/)
1756
+ found_gems[gem_name] = match[1] if match
1757
+ end
1758
+ found_gems
1759
+ end
1760
+ end
1761
+ end
1762
+ end
1763
+ end
1764
+
1765
+ ```
1766
+
1767
+ ---
1768
+ #### File: `lib/contextizer/providers/ruby/project_info.rb`
1769
+ ```ruby
1770
+ # frozen_string_literal: true
1771
+
1772
+ module Contextizer
1773
+ module Providers
1774
+ module Ruby
1775
+ class ProjectInfo < BaseProvider
1776
+ def self.call(context:, config:)
1777
+ project_info = detect_project_info(context.target_path)
1778
+ context.metadata[:project] = project_info
1779
+ end
1780
+
1781
+
1782
+ def self.detect_project_info(path)
1783
+ if Dir.glob(File.join(path, "*.gemspec")).any?
1784
+ return { type: "Gem", version: detect_gem_version(path) }
1785
+ end
1786
+
1787
+ if File.exist?(File.join(path, "bin", "rails"))
1788
+ return { type: "Rails", version: detect_rails_version(path) }
1789
+ end
1790
+
1791
+ { type: "Directory", version: "N/A" }
1792
+ end
1793
+
1794
+ def self.detect_gem_version(path)
1795
+ version_file = Dir.glob(File.join(path, "lib", "**", "version.rb")).first
1796
+ return "N/A" unless version_file
1797
+
1798
+ content = File.read(version_file)
1799
+ match = content.match(/VERSION\s*=\s*["'](.+?)["']/)
1800
+ match ? match[1] : "N/A"
1801
+ end
1802
+
1803
+ def self.detect_rails_version(path)
1804
+ gemfile_lock = File.join(path, "Gemfile.lock")
1805
+ return "N/A" unless File.exist?(gemfile_lock)
1806
+
1807
+ content = File.read(gemfile_lock)
1808
+ match = content.match(/^\s{4}rails\s\((.+?)\)/)
1809
+ match ? match[1] : "N/A"
1810
+ end
1811
+ end
1812
+ end
1813
+ end
1814
+ end
1815
+
1816
+ ```
1817
+
1818
+ ---
1819
+ #### File: `lib/contextizer/remote_repo_handler.rb`
1820
+ ```ruby
1821
+ # frozen_string_literal: true
1822
+
1823
+ require "tmpdir"
1824
+ require "fileutils"
1825
+
1826
+ module Contextizer
1827
+ class RemoteRepoHandler
1828
+ def self.handle(url)
1829
+ Dir.mktmpdir("contextizer-clone-") do |temp_path|
1830
+ puts "Cloning #{url} into temporary directory..."
1831
+
1832
+ success = system("git clone --depth 1 #{url} #{temp_path}")
1833
+
1834
+ if success
1835
+ puts "Cloning successful."
1836
+ yield(temp_path)
1837
+ else
1838
+ puts "Error: Failed to clone repository."
1839
+ end
1840
+ end
1841
+ end
1842
+ end
1843
+ end
1844
+
1845
+ ```
1846
+
1847
+ ---
1848
+ #### File: `lib/contextizer/renderers/base.rb`
1849
+ ```ruby
1850
+ # frozen_string_literal: true
1851
+
1852
+ module Contextizer
1853
+ module Renderers
1854
+ class Base
1855
+ # @param context [Contextizer::Context] The context object to be rendered.
1856
+ def self.call(context:)
1857
+ raise NotImplementedError, "#{self.name} must implement the .call method"
1858
+ end
1859
+ end
1860
+ end
1861
+ end
1862
+
1863
+ ```
1864
+
1865
+ ---
1866
+ #### File: `lib/contextizer/renderers/markdown.rb`
1867
+ ```ruby
1868
+ # frozen_string_literal: true
1869
+
1870
+ module Contextizer
1871
+ module Renderers
1872
+ class Markdown < Base
1873
+ def self.call(context:)
1874
+ new(context: context).render
1875
+ end
1876
+
1877
+ def initialize(context:)
1878
+ @context = context
1879
+ end
1880
+
1881
+ def render
1882
+ [
1883
+ render_header,
1884
+ render_file_tree,
1885
+ render_files
1886
+ ].join("\n---\n")
1887
+ end
1888
+
1889
+ private
1890
+
1891
+ def render_header
1892
+ project_info = @context.metadata[:project] || {}
1893
+ git_info = @context.metadata[:git] || {}
1894
+ gem_info = (@context.metadata[:gems] || {}).map { |n, v| "- **#{n}:** #{v}" }.join("\n")
1895
+
1896
+ packages = @context.metadata[:packages] || {}
1897
+ pkg_info = (packages[:dependencies] || {}).map { |n, v| "- **#{n}:** #{v}" }.join("\n")
1898
+
1899
+ <<~HEADER
1900
+ # Contextizer Report
1901
+
1902
+ ## Meta Context
1903
+ - **Project:** #{@context.project_name}
1904
+ - **Type:** #{project_info[:type]}
1905
+ - **Version:** #{project_info[:version]}
1906
+ - **Extracted At:** #{@context.timestamp}
1907
+
1908
+ ### Git Info
1909
+ - **Branch:** `#{git_info[:branch]}`
1910
+ - **Commit:** `#{git_info[:commit]}`
1911
+
1912
+ ### Key Ruby Dependencies
1913
+ #{gem_info.empty? ? "Not found." : gem_info}
1914
+
1915
+ ### Key JS Dependencies
1916
+ #{pkg_info.empty? ? "Not found." : pkg_info}
1917
+ HEADER
1918
+ end
1919
+
1920
+ def render_file_tree
1921
+ paths = @context.files.map { |f| f[:path] }
1922
+ tree_hash = build_path_hash(paths)
1923
+ tree_string = format_tree_node(tree_hash).join("\n")
1924
+
1925
+ <<~TREE
1926
+ ### File Structure
1927
+ <details>
1928
+ <summary>Click to view file tree</summary>
1929
+
1930
+ ```text
1931
+ #{@context.project_name}/
1932
+ #{tree_string}
1933
+ ```
1934
+
1935
+ </details>
1936
+ TREE
1937
+ end
1938
+
1939
+ def build_path_hash(paths)
1940
+ paths.each_with_object({}) do |path, hash|
1941
+ path.split("/").reduce(hash) do |level, part|
1942
+ level[part] ||= {}
1943
+ end
1944
+ end
1945
+ end
1946
+
1947
+ def format_tree_node(node, prefix = "")
1948
+ output = []
1949
+ children = node.keys.sort
1950
+ children.each_with_index do |key, index|
1951
+ is_last = (index == children.length - 1)
1952
+ connector = is_last ? "└── " : "├── "
1953
+ output << "#{prefix}#{connector}#{key}"
1954
+
1955
+ if node[key].any?
1956
+ new_prefix = prefix + (is_last ? " " : "│ ")
1957
+ output.concat(format_tree_node(node[key], new_prefix))
1958
+ end
1959
+ end
1960
+ output
1961
+ end
1962
+
1963
+ def render_files
1964
+ @context.files.map do |file|
1965
+ lang = File.extname(file[:path]).delete(".")
1966
+ lang = "ruby" if lang == "rb"
1967
+ lang = "ruby" if lang == "gemspec"
1968
+ lang = "javascript" if lang == "js"
1969
+
1970
+ <<~FILE_BLOCK
1971
+ #### File: `#{file[:path]}`
1972
+ ```#{lang}
1973
+ #{file[:content]}
1974
+ ```
1975
+ FILE_BLOCK
1976
+ end.join("\n---\n")
1977
+ end
1978
+ end
1979
+ end
1980
+ end
1981
+
1982
+ ```
1983
+
1984
+ ---
1985
+ #### File: `lib/contextizer/version.rb`
1986
+ ```ruby
1987
+ # frozen_string_literal: true
1988
+
1989
+ module Contextizer
1990
+ VERSION = "0.1.0"
1991
+ end
1992
+
1993
+ ```
1994
+
1995
+ ---
1996
+ #### File: `lib/contextizer/writer.rb`
1997
+ ```ruby
1998
+ # frozen_string_literal: true
1999
+
2000
+ require "fileutils"
2001
+
2002
+ module Contextizer
2003
+ class Writer
2004
+ def self.call(content:, destination:)
2005
+ path = Pathname.new(destination)
2006
+ puts "[Contextizer] Writer: Saving report to #{path}..."
2007
+
2008
+ FileUtils.mkdir_p(path.dirname)
2009
+ File.write(path, content)
2010
+
2011
+ puts "[Contextizer] Writer: Report saved successfully."
2012
+ end
2013
+ end
2014
+ end
2015
+
2016
+ ```
2017
+
2018
+ ---
2019
+ #### File: `lib/contextizer.rb`
2020
+ ```ruby
2021
+ # frozen_string_literal: true
2022
+
2023
+ require "zeitwerk"
2024
+ loader = Zeitwerk::Loader.for_gem
2025
+ loader.inflector.inflect(
2026
+ "cli" => "CLI",
2027
+ "javascript" => "JavaScript",
2028
+ )
2029
+ loader.setup
2030
+
2031
+ require_relative "contextizer/version"
2032
+
2033
+ module Contextizer
2034
+ class Error < StandardError; end
2035
+ # ...
2036
+ end
2037
+
2038
+ loader.eager_load
2039
+
2040
+ ```
2041
+
2042
+ ---
2043
+ #### File: `README.md`
2044
+ ```md
2045
+ # Contextizer
2046
+
2047
+ **Contextizer** is a versatile command-line tool for extracting, analyzing, and packaging the context of any software project. It scans a codebase, gathers key metadata (language, framework, dependencies, git status), and aggregates the source code into a single, easy-to-digest Markdown report.
2048
+
2049
+ It's the perfect tool for:
2050
+ - Preparing context for analysis by Large Language Models (LLMs).
2051
+ - Quickly onboarding a new developer to a project.
2052
+ - Archiving a project snapshot for a code review.
2053
+ - Creating comprehensive bug reports.
2054
+
2055
+ ---
2056
+
2057
+ ## Key Features
2058
+
2059
+ * **Polyglot by Design:** Automatically detects a project's primary language and framework (Ruby/Rails, JavaScript, etc.) using a smart "signals and weights" system.
2060
+ * **Plug-and-Play Architecture:** Easily extendable to support new languages and frameworks by adding new "Analyzers" and "Providers".
2061
+ * **Rich Metadata Collection:** Extracts Git information (branch, commit), key dependencies (`Gemfile`, `package.json`), and the project's structure.
2062
+ * **Remote Repository Analysis:** Can clone and analyze any public Git repository directly from a URL, no manual cloning required.
2063
+ * **Flexible Configuration:** Controlled via a simple YAML file (`.contextizer.yml`) in your project's root, allowing you to fine-tune the data collection process.
2064
+ * **Clean & Readable Reports:** Generates a single Markdown file with a visual file tree, project metadata, and syntax-highlighted source code.
2065
+
2066
+ ---
2067
+
2068
+ ## Installation
2069
+
2070
+ ### Standalone (Recommended)
2071
+
2072
+ Install the gem globally to use it in any project on your system:
2073
+
2074
+ ```bash
2075
+ gem install contextizer
2076
+ ```
2077
+
2078
+ ### As a Project Dependency (Bundler)
2079
+
2080
+ Add it to your project's `Gemfile` within the `:development` group:
2081
+
2082
+ ```ruby
2083
+ # Gemfile
2084
+ group :development do
2085
+ gem 'contextizer'
2086
+ end
2087
+ ```
2088
+
2089
+ Then, execute:
2090
+
2091
+ ```bash
2092
+ bundle install
2093
+ ```
2094
+
2095
+ ---
2096
+
2097
+ ## Usage
2098
+
2099
+ ### Analyzing a Local Project
2100
+
2101
+ Navigate to your project's root directory and run:
2102
+
2103
+ ```bash
2104
+ contextizer extract
2105
+ ```
2106
+
2107
+ The report will be saved in the current directory by default.
2108
+
2109
+ ### Analyzing a Remote Git Repository
2110
+
2111
+ Use the `--git-url` option to analyze any public repository:
2112
+
2113
+ ```bash
2114
+ contextizer extract --git-url https://github.com/rails/rails
2115
+ ```
2116
+
2117
+ ### Common Options
2118
+
2119
+ - `[TARGET_PATH]`: (Optional) The path to the directory to analyze. Defaults to the current directory.
2120
+ - `--git-url URL`: The URL of a remote Git repository to analyze.
2121
+ - `-o, --output PATH`: The destination path for the final report file.
2122
+ - `-f, --format FORMAT`: The output format (currently supports `markdown`).
2123
+
2124
+ ---
2125
+
2126
+ ## ⚙️ Configuration
2127
+
2128
+ To customize Contextizer for your project, create a `.contextizer.yml` file in its root directory.
2129
+
2130
+ The tool uses a three-tiered configuration system with the following priority:
2131
+
2132
+ CLI Options > .contextizer.yml > Gem Defaults
2133
+
2134
+ ### Example `.contextizer.yml`
2135
+
2136
+ YAML
2137
+
2138
+ ```
2139
+ # .contextizer.yml
2140
+
2141
+ # Path to save the report.
2142
+ # Available placeholders: {project_name}, {timestamp}
2143
+ output: "docs/context/{project_name}_{timestamp}.md"
2144
+
2145
+ # Settings for specific providers
2146
+ settings:
2147
+ # Settings for the Ruby gems provider
2148
+ gems:
2149
+ key_gems: # List your project's most important gems here
2150
+ - rails
2151
+ - devise
2152
+ - sidekiq
2153
+ - rspec-rails
2154
+ - pg
2155
+ - pundit
2156
+
2157
+ # Settings for the filesystem provider
2158
+ filesystem:
2159
+ # Specify which files and directories to include in the report
2160
+ components:
2161
+ models: "app/models/**/*.rb"
2162
+ controllers: "app/controllers/**/*.rb"
2163
+ services: "app/services/**/*.rb"
2164
+ javascript: "app/javascript/**/*.js"
2165
+ config:
2166
+ - "config/routes.rb"
2167
+ - "config/application.rb"
2168
+ documentation:
2169
+ - "README.md"
2170
+ - "CONTRIBUTING.md"
2171
+
2172
+ # Global exclusion patterns
2173
+ exclude:
2174
+ - "tmp/**/*"
2175
+ - "log/**/*"
2176
+ - "node_modules/**/*"
2177
+ - ".git/**/*"
2178
+ - "vendor/**/*"
2179
+ ```
2180
+
2181
+ ---
2182
+
2183
+ ## Extensibility (Adding a New Language)
2184
+
2185
+ Thanks to the plug-and-play architecture, adding support for a new language is straightforward:
2186
+
2187
+ 1. **Create a Specialist Analyzer**: Add a new file in `lib/contextizer/analyzers/` that detects the language based on its characteristic files and directories.
2188
+ 2. **Create Providers**: Add a new directory in `lib/contextizer/providers/` with providers that extract language-specific information (e.g., dependencies from a `pom.xml` file for Java).
2189
+
2190
+ The main `Analyzer` and `Collector` will automatically discover and use your new components.
2191
+
2192
+ ---
2193
+
2194
+ ## Contributing
2195
+
2196
+ 1. Fork the repository.
2197
+ 2. Create your feature branch (`git checkout -b my-new-feature`).
2198
+ 3. Commit your changes (`git commit -am 'Add some feature'`).
2199
+ 4. Push to the branch (`git push origin my-new-feature`).
2200
+ 5. Create a new Pull Request.
2201
+
2202
+
2203
+ ---
2204
+
2205
+ ## License
2206
+
2207
+ This project is released under the MIT License.
2208
+
2209
+ ```
2210
+
2211
+ ---
2212
+ #### File: `CHANGELOG.md`
2213
+ ```md
2214
+ ## [Unreleased]
2215
+
2216
+ ## [0.1.0] - 2025-08-31
2217
+
2218
+ - Initial release
2219
+
2220
+ ```
2221
+
2222
+ ---
2223
+ #### File: `CHANGELOG.md`
2224
+ ```md
2225
+ ## [Unreleased]
2226
+
2227
+ ## [0.1.0] - 2025-08-31
2228
+
2229
+ - Initial release
2230
+
2231
+ ```
2232
+
2233
+ ---
2234
+ #### File: `CODE_OF_CONDUCT.md`
2235
+ ```md
2236
+ # Contributor Covenant Code of Conduct
2237
+
2238
+ ## Our Pledge
2239
+
2240
+ We as members, contributors, and leaders pledge to make participation in our
2241
+ community a harassment-free experience for everyone, regardless of age, body
2242
+ size, visible or invisible disability, ethnicity, sex characteristics, gender
2243
+ identity and expression, level of experience, education, socio-economic status,
2244
+ nationality, personal appearance, race, caste, color, religion, or sexual
2245
+ identity and orientation.
2246
+
2247
+ We pledge to act and interact in ways that contribute to an open, welcoming,
2248
+ diverse, inclusive, and healthy community.
2249
+
2250
+ ## Our Standards
2251
+
2252
+ Examples of behavior that contributes to a positive environment for our
2253
+ community include:
2254
+
2255
+ * Demonstrating empathy and kindness toward other people
2256
+ * Being respectful of differing opinions, viewpoints, and experiences
2257
+ * Giving and gracefully accepting constructive feedback
2258
+ * Accepting responsibility and apologizing to those affected by our mistakes,
2259
+ and learning from the experience
2260
+ * Focusing on what is best not just for us as individuals, but for the overall
2261
+ community
2262
+
2263
+ Examples of unacceptable behavior include:
2264
+
2265
+ * The use of sexualized language or imagery, and sexual attention or advances of
2266
+ any kind
2267
+ * Trolling, insulting or derogatory comments, and personal or political attacks
2268
+ * Public or private harassment
2269
+ * Publishing others' private information, such as a physical or email address,
2270
+ without their explicit permission
2271
+ * Other conduct which could reasonably be considered inappropriate in a
2272
+ professional setting
2273
+
2274
+ ## Enforcement Responsibilities
2275
+
2276
+ Community leaders are responsible for clarifying and enforcing our standards of
2277
+ acceptable behavior and will take appropriate and fair corrective action in
2278
+ response to any behavior that they deem inappropriate, threatening, offensive,
2279
+ or harmful.
2280
+
2281
+ Community leaders have the right and responsibility to remove, edit, or reject
2282
+ comments, commits, code, wiki edits, issues, and other contributions that are
2283
+ not aligned to this Code of Conduct, and will communicate reasons for moderation
2284
+ decisions when appropriate.
2285
+
2286
+ ## Scope
2287
+
2288
+ This Code of Conduct applies within all community spaces, and also applies when
2289
+ an individual is officially representing the community in public spaces.
2290
+ Examples of representing our community include using an official email address,
2291
+ posting via an official social media account, or acting as an appointed
2292
+ representative at an online or offline event.
2293
+
2294
+ ## Enforcement
2295
+
2296
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
2297
+ reported to the community leaders responsible for enforcement at
2298
+ [INSERT CONTACT METHOD].
2299
+ All complaints will be reviewed and investigated promptly and fairly.
2300
+
2301
+ All community leaders are obligated to respect the privacy and security of the
2302
+ reporter of any incident.
2303
+
2304
+ ## Enforcement Guidelines
2305
+
2306
+ Community leaders will follow these Community Impact Guidelines in determining
2307
+ the consequences for any action they deem in violation of this Code of Conduct:
2308
+
2309
+ ### 1. Correction
2310
+
2311
+ **Community Impact**: Use of inappropriate language or other behavior deemed
2312
+ unprofessional or unwelcome in the community.
2313
+
2314
+ **Consequence**: A private, written warning from community leaders, providing
2315
+ clarity around the nature of the violation and an explanation of why the
2316
+ behavior was inappropriate. A public apology may be requested.
2317
+
2318
+ ### 2. Warning
2319
+
2320
+ **Community Impact**: A violation through a single incident or series of
2321
+ actions.
2322
+
2323
+ **Consequence**: A warning with consequences for continued behavior. No
2324
+ interaction with the people involved, including unsolicited interaction with
2325
+ those enforcing the Code of Conduct, for a specified period of time. This
2326
+ includes avoiding interactions in community spaces as well as external channels
2327
+ like social media. Violating these terms may lead to a temporary or permanent
2328
+ ban.
2329
+
2330
+ ### 3. Temporary Ban
2331
+
2332
+ **Community Impact**: A serious violation of community standards, including
2333
+ sustained inappropriate behavior.
2334
+
2335
+ **Consequence**: A temporary ban from any sort of interaction or public
2336
+ communication with the community for a specified period of time. No public or
2337
+ private interaction with the people involved, including unsolicited interaction
2338
+ with those enforcing the Code of Conduct, is allowed during this period.
2339
+ Violating these terms may lead to a permanent ban.
2340
+
2341
+ ### 4. Permanent Ban
2342
+
2343
+ **Community Impact**: Demonstrating a pattern of violation of community
2344
+ standards, including sustained inappropriate behavior, harassment of an
2345
+ individual, or aggression toward or disparagement of classes of individuals.
2346
+
2347
+ **Consequence**: A permanent ban from any sort of public interaction within the
2348
+ community.
2349
+
2350
+ ## Attribution
2351
+
2352
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage],
2353
+ version 2.1, available at
2354
+ [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
2355
+
2356
+ Community Impact Guidelines were inspired by
2357
+ [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
2358
+
2359
+ For answers to common questions about this code of conduct, see the FAQ at
2360
+ [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
2361
+ [https://www.contributor-covenant.org/translations][translations].
2362
+
2363
+ [homepage]: https://www.contributor-covenant.org
2364
+ [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
2365
+ [Mozilla CoC]: https://github.com/mozilla/diversity
2366
+ [FAQ]: https://www.contributor-covenant.org/faq
2367
+ [translations]: https://www.contributor-covenant.org/translations
2368
+
2369
+ ```
2370
+
2371
+ ---
2372
+ #### File: `README.md`
2373
+ ```md
2374
+ # Contextizer
2375
+
2376
+ **Contextizer** is a versatile command-line tool for extracting, analyzing, and packaging the context of any software project. It scans a codebase, gathers key metadata (language, framework, dependencies, git status), and aggregates the source code into a single, easy-to-digest Markdown report.
2377
+
2378
+ It's the perfect tool for:
2379
+ - Preparing context for analysis by Large Language Models (LLMs).
2380
+ - Quickly onboarding a new developer to a project.
2381
+ - Archiving a project snapshot for a code review.
2382
+ - Creating comprehensive bug reports.
2383
+
2384
+ ---
2385
+
2386
+ ## Key Features
2387
+
2388
+ * **Polyglot by Design:** Automatically detects a project's primary language and framework (Ruby/Rails, JavaScript, etc.) using a smart "signals and weights" system.
2389
+ * **Plug-and-Play Architecture:** Easily extendable to support new languages and frameworks by adding new "Analyzers" and "Providers".
2390
+ * **Rich Metadata Collection:** Extracts Git information (branch, commit), key dependencies (`Gemfile`, `package.json`), and the project's structure.
2391
+ * **Remote Repository Analysis:** Can clone and analyze any public Git repository directly from a URL, no manual cloning required.
2392
+ * **Flexible Configuration:** Controlled via a simple YAML file (`.contextizer.yml`) in your project's root, allowing you to fine-tune the data collection process.
2393
+ * **Clean & Readable Reports:** Generates a single Markdown file with a visual file tree, project metadata, and syntax-highlighted source code.
2394
+
2395
+ ---
2396
+
2397
+ ## Installation
2398
+
2399
+ ### Standalone (Recommended)
2400
+
2401
+ Install the gem globally to use it in any project on your system:
2402
+
2403
+ ```bash
2404
+ gem install contextizer
2405
+ ```
2406
+
2407
+ ### As a Project Dependency (Bundler)
2408
+
2409
+ Add it to your project's `Gemfile` within the `:development` group:
2410
+
2411
+ ```ruby
2412
+ # Gemfile
2413
+ group :development do
2414
+ gem 'contextizer'
2415
+ end
2416
+ ```
2417
+
2418
+ Then, execute:
2419
+
2420
+ ```bash
2421
+ bundle install
2422
+ ```
2423
+
2424
+ ---
2425
+
2426
+ ## Usage
2427
+
2428
+ ### Analyzing a Local Project
2429
+
2430
+ Navigate to your project's root directory and run:
2431
+
2432
+ ```bash
2433
+ contextizer extract
2434
+ ```
2435
+
2436
+ The report will be saved in the current directory by default.
2437
+
2438
+ ### Analyzing a Remote Git Repository
2439
+
2440
+ Use the `--git-url` option to analyze any public repository:
2441
+
2442
+ ```bash
2443
+ contextizer extract --git-url https://github.com/rails/rails
2444
+ ```
2445
+
2446
+ ### Common Options
2447
+
2448
+ - `[TARGET_PATH]`: (Optional) The path to the directory to analyze. Defaults to the current directory.
2449
+ - `--git-url URL`: The URL of a remote Git repository to analyze.
2450
+ - `-o, --output PATH`: The destination path for the final report file.
2451
+ - `-f, --format FORMAT`: The output format (currently supports `markdown`).
2452
+
2453
+ ---
2454
+
2455
+ ## ⚙️ Configuration
2456
+
2457
+ To customize Contextizer for your project, create a `.contextizer.yml` file in its root directory.
2458
+
2459
+ The tool uses a three-tiered configuration system with the following priority:
2460
+
2461
+ CLI Options > .contextizer.yml > Gem Defaults
2462
+
2463
+ ### Example `.contextizer.yml`
2464
+
2465
+ YAML
2466
+
2467
+ ```
2468
+ # .contextizer.yml
2469
+
2470
+ # Path to save the report.
2471
+ # Available placeholders: {project_name}, {timestamp}
2472
+ output: "docs/context/{project_name}_{timestamp}.md"
2473
+
2474
+ # Settings for specific providers
2475
+ settings:
2476
+ # Settings for the Ruby gems provider
2477
+ gems:
2478
+ key_gems: # List your project's most important gems here
2479
+ - rails
2480
+ - devise
2481
+ - sidekiq
2482
+ - rspec-rails
2483
+ - pg
2484
+ - pundit
2485
+
2486
+ # Settings for the filesystem provider
2487
+ filesystem:
2488
+ # Specify which files and directories to include in the report
2489
+ components:
2490
+ models: "app/models/**/*.rb"
2491
+ controllers: "app/controllers/**/*.rb"
2492
+ services: "app/services/**/*.rb"
2493
+ javascript: "app/javascript/**/*.js"
2494
+ config:
2495
+ - "config/routes.rb"
2496
+ - "config/application.rb"
2497
+ documentation:
2498
+ - "README.md"
2499
+ - "CONTRIBUTING.md"
2500
+
2501
+ # Global exclusion patterns
2502
+ exclude:
2503
+ - "tmp/**/*"
2504
+ - "log/**/*"
2505
+ - "node_modules/**/*"
2506
+ - ".git/**/*"
2507
+ - "vendor/**/*"
2508
+ ```
2509
+
2510
+ ---
2511
+
2512
+ ## Extensibility (Adding a New Language)
2513
+
2514
+ Thanks to the plug-and-play architecture, adding support for a new language is straightforward:
2515
+
2516
+ 1. **Create a Specialist Analyzer**: Add a new file in `lib/contextizer/analyzers/` that detects the language based on its characteristic files and directories.
2517
+ 2. **Create Providers**: Add a new directory in `lib/contextizer/providers/` with providers that extract language-specific information (e.g., dependencies from a `pom.xml` file for Java).
2518
+
2519
+ The main `Analyzer` and `Collector` will automatically discover and use your new components.
2520
+
2521
+ ---
2522
+
2523
+ ## Contributing
2524
+
2525
+ 1. Fork the repository.
2526
+ 2. Create your feature branch (`git checkout -b my-new-feature`).
2527
+ 3. Commit your changes (`git commit -am 'Add some feature'`).
2528
+ 4. Push to the branch (`git push origin my-new-feature`).
2529
+ 5. Create a new Pull Request.
2530
+
2531
+
2532
+ ---
2533
+
2534
+ ## License
2535
+
2536
+ This project is released under the MIT License.
2537
+
2538
+ ```