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.
data/exe/contextizer ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "contextizer"
6
+
7
+ Contextizer::CLI.start(ARGV)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ class Analyzer
5
+ SPECIALISTS = Analyzers.constants.map do |const|
6
+ Analyzers.const_get(const)
7
+ end.select { |const| const.is_a?(Class) && const < Analyzers::Base }
8
+
9
+ def self.call(target_path:)
10
+ new(target_path: target_path).analyze
11
+ end
12
+
13
+ def initialize(target_path:)
14
+ @target_path = target_path
15
+ end
16
+
17
+ def analyze
18
+ results = SPECIALISTS.map do |specialist_class|
19
+ specialist_class.call(target_path: @target_path)
20
+ end.compact
21
+
22
+ return { language: :unknown, framework: nil, scores: {} } if results.empty?
23
+
24
+ best_result = results.max_by { |result| result[:score] }
25
+
26
+ {
27
+ language: best_result[:language],
28
+ framework: best_result[:framework],
29
+ scores: results.map { |r| [r[:language], r[:score]] }.to_h
30
+ }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Analyzers
5
+ class Base
6
+ def self.call(target_path:)
7
+ new(target_path: target_path).analyze
8
+ end
9
+
10
+ def initialize(target_path:)
11
+ @target_path = Pathname.new(target_path)
12
+ @score = 0
13
+ end
14
+
15
+ def analyze
16
+ raise NotImplementedError, "#{self.class.name} must implement #analyze"
17
+ end
18
+
19
+ protected
20
+
21
+ def check_signal(signal)
22
+ path = @target_path.join(signal[:path])
23
+ case signal[:type]
24
+ when :file
25
+ @target_path.glob(signal[:path]).any?
26
+ when :dir
27
+ path.directory?
28
+ else
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Analyzers
5
+ class JavaScriptAnalyzer < Base
6
+ LANGUAGE = :javascript
7
+
8
+ SIGNALS = [
9
+ { type: :file, path: "package.json", weight: 5 },
10
+ { type: :dir, path: "node_modules", weight: 10 },
11
+ { type: :file, path: "webpack.config.js", weight: 5 },
12
+ { type: :file, path: "vite.config.js", weight: 5 }
13
+ ].freeze
14
+
15
+ FRAMEWORK_SIGNALS = {}.freeze
16
+
17
+ def analyze
18
+ SIGNALS.each { |signal| @score += signal[:weight] if check_signal(signal) }
19
+
20
+ return nil if @score.zero?
21
+
22
+ {
23
+ language: LANGUAGE,
24
+ framework: nil,
25
+ score: @score
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Analyzers
5
+ class PythonAnalyzer < Base
6
+ LANGUAGE = :python
7
+
8
+ SIGNALS = [
9
+ { type: :file, path: "requirements.txt", weight: 10 },
10
+ { type: :file, path: "pyproject.toml", weight: 10 },
11
+ ].freeze
12
+
13
+ FRAMEWORK_SIGNALS = {
14
+ # rails: [{ type: :file, path: "bin/rails", weight: 15 }]
15
+ }.freeze
16
+
17
+ def analyze
18
+ SIGNALS.each { |signal| @score += signal[:weight] if check_signal(signal) }
19
+
20
+ return nil if @score.zero?
21
+
22
+ {
23
+ language: LANGUAGE,
24
+ framework: detect_framework,
25
+ score: @score
26
+ }
27
+ end
28
+
29
+ private
30
+
31
+ def detect_framework
32
+ (FRAMEWORK_SIGNALS || {}).each do |fw, signals|
33
+ return fw if signals.any? { |signal| check_signal(signal) }
34
+ end
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Analyzers
5
+ class RubyAnalyzer < Base
6
+ LANGUAGE = :ruby
7
+
8
+ SIGNALS = [
9
+ { type: :file, path: "Gemfile", weight: 10 },
10
+ { type: :file, path: "*.gemspec", weight: 20 },
11
+ { type: :dir, path: "app/controllers", weight: 5 }
12
+ ].freeze
13
+
14
+ FRAMEWORK_SIGNALS = {
15
+ rails: [{ type: :file, path: "bin/rails", weight: 15 }]
16
+ }.freeze
17
+
18
+ def analyze
19
+ SIGNALS.each { |signal| @score += signal[:weight] if check_signal(signal) }
20
+
21
+ return nil if @score.zero?
22
+
23
+ {
24
+ language: LANGUAGE,
25
+ framework: detect_framework,
26
+ score: @score
27
+ }
28
+ end
29
+
30
+ private
31
+
32
+ def detect_framework
33
+ (FRAMEWORK_SIGNALS || {}).each do |fw, signals|
34
+ return fw if signals.any? { |signal| check_signal(signal) }
35
+ end
36
+ nil
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "yaml"
5
+
6
+ module Contextizer
7
+ class CLI < Thor
8
+ desc "extract [TARGET_PATH]", "Extracts project context into a single file."
9
+ option :git_url,
10
+ type: :string,
11
+ desc: "URL of a remote git repository to analyze instead of a local path."
12
+ option :output,
13
+ aliases: "-o",
14
+ type: :string,
15
+ desc: "Output file path (overrides config)."
16
+ option :format,
17
+ aliases: "-f",
18
+ type: :string,
19
+ desc: "Output format (e.g., markdown, json)."
20
+
21
+ RENDERER_MAPPING = {
22
+ "markdown" => Renderers::Markdown
23
+ }.freeze
24
+
25
+ def extract(target_path = ".")
26
+ if options[:git_url]
27
+ RemoteRepoHandler.handle(options[:git_url]) do |remote_path|
28
+ run_extraction(remote_path)
29
+ end
30
+ else
31
+ run_extraction(target_path)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def run_extraction(path)
38
+ cli_options = options.transform_keys(&:to_s).compact
39
+ config = Configuration.load(cli_options)
40
+
41
+ command_string = "contextizer #{ARGV.join(" ")}"
42
+ context = Collector.call(
43
+ config: config,
44
+ target_path: path,
45
+ command: command_string
46
+ )
47
+
48
+ renderer = RENDERER_MAPPING[config.format]
49
+ raise Error, "Unsupported format: '#{config.format}'" unless renderer
50
+
51
+ rendered_output = renderer.call(context: context)
52
+
53
+ destination_path = resolve_output_path(config.output, context)
54
+ Writer.call(content: rendered_output, destination: destination_path)
55
+
56
+ puts "\nContextizer: Extraction complete! ✨"
57
+ end
58
+
59
+ def resolve_output_path(path_template, context)
60
+ timestamp = Time.now.strftime("%Y-%m-%d_%H%M")
61
+ project_name = context.metadata.dig(:project, :type) == "Gem" ? context.project_name : "project"
62
+
63
+
64
+ path_template
65
+ .gsub("{profile}", "default")
66
+ .gsub("{timestamp}", timestamp)
67
+ .gsub("{project_name}", project_name)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ class Collector
5
+ BASE_PROVIDERS = [
6
+ Providers::Base::ProjectName,
7
+ Providers::Base::Git,
8
+ Providers::Base::FileSystem
9
+ ].freeze
10
+
11
+ LANGUAGE_MODULES = {
12
+ ruby: Providers::Ruby,
13
+ javascript: Providers::JavaScript
14
+ }.freeze
15
+
16
+ def self.call(config:, target_path:, command:)
17
+ new(config: config, target_path: target_path, command: command).call
18
+ end
19
+
20
+ def initialize(config:, target_path:, command:)
21
+ @config = config
22
+ @target_path = target_path
23
+ @command = command
24
+ @context = Context.new(
25
+ target_path: target_path,
26
+ command: @command
27
+ )
28
+ end
29
+
30
+ def call
31
+ stack = Analyzer.call(target_path: @target_path)
32
+ @context.metadata[:stack] = stack
33
+
34
+ BASE_PROVIDERS.each do |provider_class|
35
+ provider_class.call(context: @context, config: @config)
36
+ end
37
+
38
+ language_module = LANGUAGE_MODULES[stack[:language]]
39
+ run_language_providers(language_module) if language_module
40
+
41
+ puts "Collector: Collection complete. Found #{@context.files.count} files."
42
+ @context
43
+ end
44
+
45
+ private
46
+
47
+ def run_language_providers(language_module)
48
+ puts "Collector: Running '#{language_module.name.split('::').last}' providers..."
49
+ language_module.constants.each do |const_name|
50
+ provider_class = language_module.const_get(const_name)
51
+ if provider_class.is_a?(Class) && provider_class < Providers::BaseProvider
52
+ provider_class.call(context: @context, config: @config)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "pathname"
5
+
6
+ module Contextizer
7
+ class Configuration
8
+ # CLI options > Project Config > Default Config
9
+ def self.load(cli_options = {})
10
+ default_config_path = Pathname.new(__dir__).join("../../config/default.yml")
11
+ default_config = YAML.load_file(default_config_path)
12
+
13
+ project_config_path = find_project_config
14
+ project_config = project_config_path ? YAML.load_file(project_config_path) : {}
15
+
16
+ merged_config = deep_merge(default_config, project_config)
17
+ merged_config = deep_merge(merged_config, cli_options)
18
+
19
+ new(merged_config)
20
+ end
21
+
22
+ def initialize(options)
23
+ @options = options
24
+ end
25
+
26
+ def method_missing(name, *args, &block)
27
+ key = name.to_s
28
+ if @options.key?(key)
29
+ @options[key]
30
+ else
31
+ super
32
+ end
33
+ end
34
+
35
+ def respond_to_missing?(name, include_private = false)
36
+ @options.key?(name.to_s) || super
37
+ end
38
+
39
+ def self.find_project_config(path = Dir.pwd)
40
+ path = Pathname.new(path)
41
+ loop do
42
+ config_file = path.join(".contextizer.yml")
43
+ return config_file if config_file.exist?
44
+ break if path.root?
45
+
46
+ path = path.parent
47
+ end
48
+ nil
49
+ end
50
+
51
+ def self.deep_merge(hash1, hash2)
52
+ hash1.merge(hash2) do |key, old_val, new_val|
53
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
54
+ deep_merge(old_val, new_val)
55
+ else
56
+ new_val
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ # A value object that holds all the collected information about a project.
5
+ # This object is the result of the Collector phase and the input for the Renderer phase.
6
+ Context = Struct.new(
7
+ :project_name,
8
+ :target_path,
9
+ :timestamp,
10
+ :command,
11
+ :metadata, # Hash for data from providers like Git, Gems, etc.
12
+ :files, # Array of file objects { path:, content: }
13
+ keyword_init: true
14
+ ) do
15
+ def initialize(*)
16
+ super
17
+ self.metadata ||= {}
18
+ self.files ||= []
19
+ self.timestamp ||= Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Contextizer
6
+ module Providers
7
+ module Base
8
+ class FileSystem < BaseProvider
9
+ def self.call(context:, config:)
10
+ new(context: context, config: config).collect_files
11
+ end
12
+
13
+ def initialize(context:, config:)
14
+ @context = context
15
+ @config = config.settings["filesystem"]
16
+ @target_path = Pathname.new(context.target_path)
17
+ end
18
+
19
+ def collect_files
20
+ file_paths = find_matching_files
21
+
22
+ file_paths.each do |path|
23
+ content = read_file_content(path)
24
+ next unless content
25
+
26
+ @context.files << {
27
+ path: path.relative_path_from(@target_path).to_s,
28
+ content: content
29
+ }
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def find_matching_files
36
+ patterns = @config["components"].values.flatten.map do |pattern|
37
+ (@target_path / pattern).to_s
38
+ end
39
+
40
+ all_files = Dir.glob(patterns)
41
+
42
+ exclude_patterns = @config["exclude"].map do |pattern|
43
+ (@target_path / pattern).to_s
44
+ end
45
+
46
+ all_files.reject do |file|
47
+ exclude_patterns.any? { |pattern| File.fnmatch?(pattern, file) }
48
+ end.map { |file| Pathname.new(file) }
49
+ end
50
+
51
+ def read_file_content(path)
52
+ path.read
53
+ rescue Errno::ENOENT, IOError => e
54
+ warn "Warning: Could not read file #{path}: #{e.message}"
55
+ nil
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Providers
5
+ module Base
6
+ class Git < BaseProvider
7
+ def self.call(context:, config:)
8
+ context.metadata[:git] = fetch_git_info(context.target_path)
9
+ @config = config
10
+ end
11
+
12
+ def self.fetch_git_info(path)
13
+ Dir.chdir(path) do
14
+ {
15
+ branch: `git rev-parse --abbrev-ref HEAD`.strip,
16
+ commit: `git rev-parse HEAD`.strip[0, 8]
17
+ }
18
+ end
19
+ rescue StandardError
20
+ { branch: "N/A", commit: "N/A" }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Providers
5
+ module Base
6
+ class ProjectName < BaseProvider
7
+ def self.call(context:, config:)
8
+ context.project_name = detect_project_name(context.target_path)
9
+ end
10
+
11
+ private
12
+
13
+ def self.detect_project_name(path)
14
+ git_name = name_from_git_remote(path)
15
+ return git_name if git_name
16
+
17
+ File.basename(path)
18
+ end
19
+
20
+ def self.name_from_git_remote(path)
21
+ return nil unless Dir.exist?(File.join(path, ".git"))
22
+
23
+ Dir.chdir(path) do
24
+ remote_url = `git remote get-url origin`.strip
25
+ remote_url.split("/").last.sub(/\.git$/, "")
26
+ end
27
+ rescue StandardError
28
+ nil
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Providers
5
+ class BaseProvider
6
+ # @param context [Contextizer::Context] The context object to be populated.
7
+ # @param config [Contextizer::Configuration] The overall configuration.
8
+ def self.call(context:, config:)
9
+ raise NotImplementedError, "#{self.name} must implement the .call method"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Contextizer
5
+ module Providers
6
+ module JavaScript
7
+ class Packages < BaseProvider
8
+ def self.call(context:, config:)
9
+ package_json_path = File.join(context.target_path, "package.json")
10
+ return unless File.exist?(package_json_path)
11
+
12
+ context.metadata[:packages] = parse_packages(package_json_path)
13
+ end
14
+
15
+ private
16
+
17
+ def self.parse_packages(path)
18
+ begin
19
+ file = File.read(path)
20
+ data = JSON.parse(file)
21
+ {
22
+ dependencies: data["dependencies"] || {},
23
+ dev_dependencies: data["devDependencies"] || {}
24
+ }
25
+ rescue JSON::ParserError => e
26
+ puts "Warning: Could not parse package.json: #{e.message}"
27
+ {}
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Providers
5
+ module Ruby
6
+ class Gems < BaseProvider
7
+ def self.call(context:, config:)
8
+ key_gems = config.settings.dig("gems", "key_gems")
9
+ return if key_gems.nil? || key_gems.empty?
10
+
11
+ gemfile_lock = File.join(context.target_path, "Gemfile.lock")
12
+ return unless File.exist?(gemfile_lock)
13
+
14
+ context.metadata[:gems] = parse_gemfile_lock(gemfile_lock, key_gems)
15
+ end
16
+
17
+ def self.parse_gemfile_lock(path, key_gems)
18
+ found_gems = {}
19
+ content = File.read(path)
20
+ key_gems.each do |gem_name|
21
+ match = content.match(/^\s{4}#{gem_name}\s\((.+?)\)/)
22
+ found_gems[gem_name] = match[1] if match
23
+ end
24
+ found_gems
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Providers
5
+ module Ruby
6
+ class ProjectInfo < BaseProvider
7
+ def self.call(context:, config:)
8
+ project_info = detect_project_info(context.target_path)
9
+ context.metadata[:project] = project_info
10
+ end
11
+
12
+
13
+ def self.detect_project_info(path)
14
+ if Dir.glob(File.join(path, "*.gemspec")).any?
15
+ return { type: "Gem", version: detect_gem_version(path) }
16
+ end
17
+
18
+ if File.exist?(File.join(path, "bin", "rails"))
19
+ return { type: "Rails", version: detect_rails_version(path) }
20
+ end
21
+
22
+ { type: "Directory", version: "N/A" }
23
+ end
24
+
25
+ def self.detect_gem_version(path)
26
+ version_file = Dir.glob(File.join(path, "lib", "**", "version.rb")).first
27
+ return "N/A" unless version_file
28
+
29
+ content = File.read(version_file)
30
+ match = content.match(/VERSION\s*=\s*["'](.+?)["']/)
31
+ match ? match[1] : "N/A"
32
+ end
33
+
34
+ def self.detect_rails_version(path)
35
+ gemfile_lock = File.join(path, "Gemfile.lock")
36
+ return "N/A" unless File.exist?(gemfile_lock)
37
+
38
+ content = File.read(gemfile_lock)
39
+ match = content.match(/^\s{4}rails\s\((.+?)\)/)
40
+ match ? match[1] : "N/A"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+
6
+ module Contextizer
7
+ class RemoteRepoHandler
8
+ def self.handle(url)
9
+ Dir.mktmpdir("contextizer-clone-") do |temp_path|
10
+ puts "Cloning #{url} into temporary directory..."
11
+
12
+ success = system("git clone --depth 1 #{url} #{temp_path}")
13
+
14
+ if success
15
+ puts "Cloning successful."
16
+ yield(temp_path)
17
+ else
18
+ puts "Error: Failed to clone repository."
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Contextizer
4
+ module Renderers
5
+ class Base
6
+ # @param context [Contextizer::Context] The context object to be rendered.
7
+ def self.call(context:)
8
+ raise NotImplementedError, "#{self.name} must implement the .call method"
9
+ end
10
+ end
11
+ end
12
+ end