spurline-core 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +177 -0
  4. data/exe/spur +6 -0
  5. data/lib/CLAUDE.md +11 -0
  6. data/lib/spurline/CLAUDE.md +16 -0
  7. data/lib/spurline/adapters/CLAUDE.md +12 -0
  8. data/lib/spurline/adapters/base.rb +17 -0
  9. data/lib/spurline/adapters/claude.rb +208 -0
  10. data/lib/spurline/adapters/open_ai.rb +213 -0
  11. data/lib/spurline/adapters/registry.rb +33 -0
  12. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  13. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  14. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  15. data/lib/spurline/agent.rb +433 -0
  16. data/lib/spurline/audit/log.rb +156 -0
  17. data/lib/spurline/audit/secret_filter.rb +121 -0
  18. data/lib/spurline/base.rb +130 -0
  19. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  20. data/lib/spurline/cartographer/analyzer.rb +71 -0
  21. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  22. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  23. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  24. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  25. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  26. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  27. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  28. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  29. data/lib/spurline/cartographer/runner.rb +88 -0
  30. data/lib/spurline/cartographer.rb +6 -0
  31. data/lib/spurline/channels/base.rb +41 -0
  32. data/lib/spurline/channels/event.rb +136 -0
  33. data/lib/spurline/channels/github.rb +205 -0
  34. data/lib/spurline/channels/router.rb +103 -0
  35. data/lib/spurline/cli/check.rb +88 -0
  36. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  37. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  38. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  39. data/lib/spurline/cli/checks/base.rb +35 -0
  40. data/lib/spurline/cli/checks/credentials.rb +43 -0
  41. data/lib/spurline/cli/checks/permissions.rb +22 -0
  42. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  43. data/lib/spurline/cli/checks/session_store.rb +97 -0
  44. data/lib/spurline/cli/console.rb +73 -0
  45. data/lib/spurline/cli/credentials.rb +181 -0
  46. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  47. data/lib/spurline/cli/generators/agent.rb +123 -0
  48. data/lib/spurline/cli/generators/migration.rb +62 -0
  49. data/lib/spurline/cli/generators/project.rb +331 -0
  50. data/lib/spurline/cli/generators/tool.rb +98 -0
  51. data/lib/spurline/cli/router.rb +121 -0
  52. data/lib/spurline/configuration.rb +23 -0
  53. data/lib/spurline/dsl/CLAUDE.md +11 -0
  54. data/lib/spurline/dsl/guardrails.rb +108 -0
  55. data/lib/spurline/dsl/hooks.rb +51 -0
  56. data/lib/spurline/dsl/memory.rb +39 -0
  57. data/lib/spurline/dsl/model.rb +23 -0
  58. data/lib/spurline/dsl/persona.rb +74 -0
  59. data/lib/spurline/dsl/suspend_until.rb +53 -0
  60. data/lib/spurline/dsl/tools.rb +176 -0
  61. data/lib/spurline/errors.rb +109 -0
  62. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  63. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  64. data/lib/spurline/lifecycle/runner.rb +456 -0
  65. data/lib/spurline/lifecycle/states.rb +47 -0
  66. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  67. data/lib/spurline/memory/CLAUDE.md +12 -0
  68. data/lib/spurline/memory/context_assembler.rb +100 -0
  69. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  70. data/lib/spurline/memory/embedder/base.rb +17 -0
  71. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  72. data/lib/spurline/memory/episode.rb +56 -0
  73. data/lib/spurline/memory/episodic_store.rb +147 -0
  74. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  75. data/lib/spurline/memory/long_term/base.rb +22 -0
  76. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  77. data/lib/spurline/memory/manager.rb +147 -0
  78. data/lib/spurline/memory/short_term.rb +57 -0
  79. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  80. data/lib/spurline/orchestration/judge.rb +109 -0
  81. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  82. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  83. data/lib/spurline/orchestration/ledger.rb +339 -0
  84. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  85. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  86. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  87. data/lib/spurline/persona/base.rb +42 -0
  88. data/lib/spurline/persona/registry.rb +42 -0
  89. data/lib/spurline/secrets/resolver.rb +65 -0
  90. data/lib/spurline/secrets/vault.rb +42 -0
  91. data/lib/spurline/security/content.rb +76 -0
  92. data/lib/spurline/security/context_pipeline.rb +58 -0
  93. data/lib/spurline/security/gates/base.rb +36 -0
  94. data/lib/spurline/security/gates/operator_config.rb +22 -0
  95. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  96. data/lib/spurline/security/gates/tool_result.rb +23 -0
  97. data/lib/spurline/security/gates/user_input.rb +22 -0
  98. data/lib/spurline/security/injection_scanner.rb +109 -0
  99. data/lib/spurline/security/pii_filter.rb +104 -0
  100. data/lib/spurline/session/CLAUDE.md +11 -0
  101. data/lib/spurline/session/resumption.rb +36 -0
  102. data/lib/spurline/session/serializer.rb +169 -0
  103. data/lib/spurline/session/session.rb +154 -0
  104. data/lib/spurline/session/store/CLAUDE.md +12 -0
  105. data/lib/spurline/session/store/base.rb +27 -0
  106. data/lib/spurline/session/store/memory.rb +45 -0
  107. data/lib/spurline/session/store/postgres.rb +123 -0
  108. data/lib/spurline/session/store/sqlite.rb +139 -0
  109. data/lib/spurline/session/suspension.rb +93 -0
  110. data/lib/spurline/session/turn.rb +98 -0
  111. data/lib/spurline/spur.rb +213 -0
  112. data/lib/spurline/streaming/CLAUDE.md +12 -0
  113. data/lib/spurline/streaming/buffer.rb +77 -0
  114. data/lib/spurline/streaming/chunk.rb +62 -0
  115. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  116. data/lib/spurline/testing.rb +245 -0
  117. data/lib/spurline/toolkit.rb +110 -0
  118. data/lib/spurline/tools/base.rb +209 -0
  119. data/lib/spurline/tools/idempotency.rb +220 -0
  120. data/lib/spurline/tools/permissions.rb +44 -0
  121. data/lib/spurline/tools/registry.rb +43 -0
  122. data/lib/spurline/tools/runner.rb +255 -0
  123. data/lib/spurline/tools/scope.rb +309 -0
  124. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  125. data/lib/spurline/version.rb +5 -0
  126. data/lib/spurline.rb +56 -0
  127. metadata +333 -0
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+ require "set"
3
+
4
+ module Spurline
5
+ module Audit
6
+ # Stateless redaction utility for tool-call argument payloads.
7
+ module SecretFilter
8
+ SENSITIVE_PATTERNS = %w[
9
+ key token secret password credential passphrase
10
+ api_key api_secret access_token refresh_token
11
+ auth bearer jwt private_key
12
+ ].freeze
13
+
14
+ class << self
15
+ # Returns a filtered copy of arguments with sensitive values redacted.
16
+ # Never mutates the original object.
17
+ def filter(arguments, tool_name:, registry: nil)
18
+ return nil if arguments.nil?
19
+
20
+ sensitive_fields = sensitive_parameters_for(tool_name, registry)
21
+ filter_value(arguments, sensitive_fields)
22
+ end
23
+
24
+ # Returns true when any sensitive key is present in arguments.
25
+ def contains_secrets?(arguments, tool_name:, registry: nil)
26
+ return false if arguments.nil?
27
+
28
+ sensitive_fields = sensitive_parameters_for(tool_name, registry)
29
+ contains_secrets_in_value?(arguments, sensitive_fields)
30
+ end
31
+
32
+ private
33
+
34
+ def filter_value(value, sensitive_fields)
35
+ case value
36
+ when Hash
37
+ value.each_with_object({}) do |(key, nested), out|
38
+ key_name = key.to_s
39
+ if sensitive_key?(key_name, sensitive_fields)
40
+ out[key] = redacted_placeholder(key_name)
41
+ else
42
+ out[key] = filter_value(nested, sensitive_fields)
43
+ end
44
+ end
45
+ when Array
46
+ value.map { |nested| filter_value(nested, sensitive_fields) }
47
+ else
48
+ value
49
+ end
50
+ end
51
+
52
+ def contains_secrets_in_value?(value, sensitive_fields)
53
+ case value
54
+ when Hash
55
+ value.any? do |key, nested|
56
+ sensitive_key?(key.to_s, sensitive_fields) ||
57
+ contains_secrets_in_value?(nested, sensitive_fields)
58
+ end
59
+ when Array
60
+ value.any? { |nested| contains_secrets_in_value?(nested, sensitive_fields) }
61
+ else
62
+ false
63
+ end
64
+ end
65
+
66
+ def sensitive_key?(name, sensitive_fields)
67
+ normalized = normalize_key(name)
68
+ return true if sensitive_fields.include?(normalized)
69
+
70
+ tokens = normalized.split("_")
71
+ SENSITIVE_PATTERNS.any? do |pattern|
72
+ pattern_tokens = pattern.split("_")
73
+ if pattern_tokens.length == 1
74
+ tokens.include?(pattern)
75
+ else
76
+ tokens.each_cons(pattern_tokens.length).any? { |slice| slice == pattern_tokens }
77
+ end
78
+ end
79
+ end
80
+
81
+ def sensitive_parameters_for(tool_name, registry)
82
+ tool = resolve_tool(tool_name, registry)
83
+ return Set.new unless tool&.respond_to?(:sensitive_parameters)
84
+
85
+ raw = tool.sensitive_parameters || Set.new
86
+ Set.new(raw.map { |name| normalize_key(name) })
87
+ rescue StandardError
88
+ Set.new
89
+ end
90
+
91
+ def resolve_tool(tool_name, registry)
92
+ return nil unless registry && tool_name
93
+
94
+ if registry.respond_to?(:registered?) && !registry.registered?(tool_name)
95
+ return nil
96
+ end
97
+
98
+ tool = registry.fetch(tool_name)
99
+ return tool.class unless tool.is_a?(Class)
100
+
101
+ tool
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ def normalize_key(name)
107
+ name.to_s
108
+ .gsub(/([a-z0-9])([A-Z])/, '\1_\2')
109
+ .strip
110
+ .downcase
111
+ .gsub(/[^a-z0-9]+/, "_")
112
+ .gsub(/\A_+|_+\z/, "")
113
+ end
114
+
115
+ def redacted_placeholder(field_name)
116
+ "[REDACTED:#{field_name}]"
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ # Framework internals. Developers never interact with this class directly.
5
+ # Includes all DSL modules and provides registry access.
6
+ #
7
+ # Default adapters are registered here so `use_model :claude_sonnet` works
8
+ # out of the box without manual registration.
9
+ class Base
10
+ include Spurline::DSL::Model
11
+ include Spurline::DSL::Persona
12
+ include Spurline::DSL::Tools
13
+ include Spurline::DSL::Memory
14
+ include Spurline::DSL::Guardrails
15
+ include Spurline::DSL::Hooks
16
+ include Spurline::DSL::SuspendUntil
17
+
18
+ # Default model-to-adapter mapping.
19
+ DEFAULT_ADAPTERS = {
20
+ claude_sonnet: { adapter: Spurline::Adapters::Claude, model: "claude-sonnet-4-20250514" },
21
+ claude_opus: { adapter: Spurline::Adapters::Claude, model: "claude-opus-4-20250514" },
22
+ claude_haiku: { adapter: Spurline::Adapters::Claude, model: "claude-haiku-4-5-20251001" },
23
+ openai_gpt4o: { adapter: Spurline::Adapters::OpenAI, model: "gpt-4o" },
24
+ openai_gpt4o_mini: { adapter: Spurline::Adapters::OpenAI, model: "gpt-4o-mini" },
25
+ openai_o3_mini: { adapter: Spurline::Adapters::OpenAI, model: "o3-mini" },
26
+ stub: { adapter: Spurline::Adapters::StubAdapter },
27
+ }.freeze
28
+
29
+ class << self
30
+ def deterministic_sequence(*tool_names)
31
+ if tool_names.empty?
32
+ raise Spurline::ConfigurationError,
33
+ "deterministic_sequence requires at least one tool name."
34
+ end
35
+
36
+ @deterministic_sequence_config = tool_names.map do |item|
37
+ item.is_a?(Hash) ? item : item.to_sym
38
+ end
39
+ end
40
+
41
+ def deterministic_sequence_config
42
+ own = instance_variable_defined?(:@deterministic_sequence_config) ? @deterministic_sequence_config : nil
43
+ if own
44
+ own
45
+ elsif superclass.respond_to?(:deterministic_sequence_config)
46
+ superclass.deterministic_sequence_config
47
+ else
48
+ nil
49
+ end
50
+ end
51
+
52
+ def tool_registry
53
+ @tool_registry ||= Spurline::Tools::Registry.new
54
+ Spurline::Spur.flush_pending_registrations!(@tool_registry)
55
+ @tool_registry
56
+ end
57
+
58
+ def toolkit_registry
59
+ @toolkit_registry ||= Spurline::Tools::ToolkitRegistry.new(tool_registry: tool_registry)
60
+ end
61
+
62
+ def adapter_registry
63
+ @adapter_registry ||= begin
64
+ registry = Spurline::Adapters::Registry.new
65
+ register_default_adapters!(registry)
66
+ registry
67
+ end
68
+ Spurline::Spur.flush_pending_adapter_registrations!(@adapter_registry)
69
+ @adapter_registry
70
+ end
71
+
72
+ def session_store
73
+ @session_store ||= resolve_session_store(Spurline.config.session_store)
74
+ end
75
+
76
+ def session_store=(store)
77
+ @session_store = resolve_session_store(store)
78
+ end
79
+
80
+ def inherited(subclass)
81
+ super
82
+ # Share registries with subclasses
83
+ subclass.instance_variable_set(:@tool_registry, tool_registry)
84
+ subclass.instance_variable_set(:@toolkit_registry, toolkit_registry)
85
+ subclass.instance_variable_set(:@adapter_registry, adapter_registry)
86
+ subclass.instance_variable_set(:@session_store, @session_store)
87
+ if instance_variable_defined?(:@deterministic_sequence_config)
88
+ subclass.instance_variable_set(
89
+ :@deterministic_sequence_config,
90
+ @deterministic_sequence_config&.dup
91
+ )
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def resolve_session_store(store)
98
+ case store
99
+ when nil, :memory
100
+ Spurline::Session::Store::Memory.new
101
+ when :sqlite
102
+ Spurline::Session::Store::SQLite.new(path: Spurline.config.session_store_path)
103
+ when :postgres
104
+ url = Spurline.config.session_store_postgres_url
105
+ unless url && !url.strip.empty?
106
+ raise Spurline::ConfigurationError,
107
+ "session_store_postgres_url must be set when using :postgres session store. " \
108
+ "Set it via Spurline.configure { |c| c.session_store_postgres_url = \"postgresql://...\" }."
109
+ end
110
+ Spurline::Session::Store::Postgres.new(url: url)
111
+ else
112
+ return store if store.respond_to?(:save) &&
113
+ store.respond_to?(:load) &&
114
+ store.respond_to?(:delete) &&
115
+ store.respond_to?(:exists?)
116
+
117
+ raise Spurline::ConfigurationError,
118
+ "Invalid session_store: #{store.inspect}. " \
119
+ "Use :memory, :sqlite, :postgres, or an object implementing save/load/delete/exists?."
120
+ end
121
+ end
122
+
123
+ def register_default_adapters!(registry)
124
+ DEFAULT_ADAPTERS.each do |name, config|
125
+ registry.register(name, config[:adapter])
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,12 @@
1
+ <claude-mem-context>
2
+ # Recent Activity
3
+
4
+ <!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
5
+
6
+ ### Feb 21, 2026
7
+
8
+ | ID | Time | T | Title | Read |
9
+ |----|------|---|-------|------|
10
+ | #3734 | 10:05 PM | 🔵 | Cartographer repository analysis system verified as complete and production-ready | ~1142 |
11
+ | #3733 | 10:04 PM | 🔵 | Cartographer repository analysis system verified complete and production-ready with all six analyzers | ~1347 |
12
+ </claude-mem-context>
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Cartographer
5
+ class Analyzer
6
+ attr_reader :repo_path, :findings
7
+
8
+ def initialize(repo_path:)
9
+ @repo_path = File.expand_path(repo_path)
10
+ @findings = {}
11
+ end
12
+
13
+ # Subclasses implement this. Returns a hash merged into RepoProfile.
14
+ def analyze
15
+ raise NotImplementedError, "#{self.class}#analyze must return a findings hash"
16
+ end
17
+
18
+ # Per-layer confidence score (0.0-1.0).
19
+ def confidence
20
+ 1.0
21
+ end
22
+
23
+ private
24
+
25
+ def file_exists?(relative_path)
26
+ return false if excluded_relative_path?(relative_path)
27
+
28
+ File.exist?(File.join(repo_path, relative_path))
29
+ end
30
+
31
+ def read_file(relative_path)
32
+ return nil if excluded_relative_path?(relative_path)
33
+
34
+ path = File.join(repo_path, relative_path)
35
+ return nil unless File.file?(path)
36
+
37
+ File.read(path)
38
+ end
39
+
40
+ def glob(pattern)
41
+ Dir.glob(File.join(repo_path, pattern)).reject do |path|
42
+ excluded_relative_path?(relative_path(path))
43
+ end
44
+ end
45
+
46
+ def relative_path(path)
47
+ path.to_s.sub(%r{\A#{Regexp.escape(repo_path)}/?}, "")
48
+ end
49
+
50
+ def excluded_relative_path?(relative_path)
51
+ normalized = relative_path.to_s.sub(%r{\A\./}, "").sub(%r{\A/}, "")
52
+ return false if normalized.empty?
53
+
54
+ excluded_patterns.any? do |pattern|
55
+ token = pattern.to_s.sub(%r{\A\./}, "").sub(%r{\A/}, "").sub(%r{/$}, "")
56
+ if token.include?("/")
57
+ normalized == token || normalized.start_with?("#{token}/")
58
+ else
59
+ normalized.split("/").include?(token)
60
+ end
61
+ end
62
+ end
63
+
64
+ def excluded_patterns
65
+ Array(Spurline.config.cartographer_exclude_patterns)
66
+ rescue StandardError
67
+ []
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,12 @@
1
+ <claude-mem-context>
2
+ # Recent Activity
3
+
4
+ <!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
5
+
6
+ ### Feb 21, 2026
7
+
8
+ | ID | Time | T | Title | Read |
9
+ |----|------|---|-------|------|
10
+ | #3734 | 10:05 PM | 🔵 | Cartographer repository analysis system verified as complete and production-ready | ~1142 |
11
+ | #3733 | 10:04 PM | 🔵 | Cartographer repository analysis system verified complete and production-ready with all six analyzers | ~1347 |
12
+ </claude-mem-context>
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Spurline
6
+ module Cartographer
7
+ module Analyzers
8
+ class CIConfig < Analyzer
9
+ def analyze
10
+ providers = []
11
+ commands = []
12
+
13
+ github_workflows = glob(".github/workflows/*.{yml,yaml}")
14
+ unless github_workflows.empty?
15
+ providers << :github_actions
16
+ github_workflows.each do |workflow_path|
17
+ commands.concat(extract_github_commands(workflow_path))
18
+ end
19
+ end
20
+
21
+ circle_config_path = File.join(repo_path, ".circleci", "config.yml")
22
+ if File.file?(circle_config_path)
23
+ providers << :circleci
24
+ commands.concat(extract_circleci_commands(circle_config_path))
25
+ end
26
+
27
+ gitlab_path = File.join(repo_path, ".gitlab-ci.yml")
28
+ if File.file?(gitlab_path)
29
+ providers << :gitlab_ci
30
+ commands.concat(extract_gitlab_commands(gitlab_path))
31
+ end
32
+
33
+ jenkinsfile_path = File.join(repo_path, "Jenkinsfile")
34
+ if File.file?(jenkinsfile_path)
35
+ providers << :jenkins
36
+ commands.concat(extract_jenkins_commands(jenkinsfile_path))
37
+ end
38
+
39
+ ci_hash = {}
40
+ ci_hash[:provider] = providers.first if providers.any?
41
+ ci_hash[:providers] = providers if providers.any?
42
+ ci_hash[:test_command] = pick_command(commands) { |cmd| test_command?(cmd) }
43
+ ci_hash[:lint_command] = pick_command(commands) { |cmd| lint_command?(cmd) }
44
+ ci_hash[:deploy_command] = pick_command(commands) { |cmd| deploy_command?(cmd) }
45
+ ci_hash.compact!
46
+
47
+ @findings = {
48
+ ci: ci_hash,
49
+ metadata: {
50
+ ci_config: {
51
+ command_count: commands.length,
52
+ },
53
+ },
54
+ }
55
+ end
56
+
57
+ def confidence
58
+ providers = findings.dig(:ci, :providers)
59
+ providers && !providers.empty? ? 1.0 : 0.5
60
+ end
61
+
62
+ private
63
+
64
+ def extract_github_commands(path)
65
+ payload = safe_yaml_load(path)
66
+ return [] unless payload.is_a?(Hash)
67
+
68
+ jobs = payload["jobs"]
69
+ return [] unless jobs.is_a?(Hash)
70
+
71
+ jobs.values.flat_map do |job|
72
+ next [] unless job.is_a?(Hash)
73
+
74
+ steps = job["steps"]
75
+ next [] unless steps.is_a?(Array)
76
+
77
+ steps.filter_map do |step|
78
+ next unless step.is_a?(Hash)
79
+
80
+ normalize_command(step["run"])
81
+ end
82
+ end
83
+ end
84
+
85
+ def extract_circleci_commands(path)
86
+ payload = safe_yaml_load(path)
87
+ return [] unless payload.is_a?(Hash)
88
+
89
+ jobs = payload["jobs"]
90
+ return [] unless jobs.is_a?(Hash)
91
+
92
+ jobs.values.flat_map do |job|
93
+ next [] unless job.is_a?(Hash)
94
+
95
+ steps = job["steps"]
96
+ next [] unless steps.is_a?(Array)
97
+
98
+ steps.filter_map do |step|
99
+ case step
100
+ when String
101
+ nil
102
+ when Hash
103
+ run = step["run"] || step[:run]
104
+ if run.is_a?(Hash)
105
+ normalize_command(run["command"] || run[:command])
106
+ else
107
+ normalize_command(run)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ def extract_gitlab_commands(path)
115
+ payload = safe_yaml_load(path)
116
+ return [] unless payload.is_a?(Hash)
117
+
118
+ payload.values.flat_map do |job|
119
+ next [] unless job.is_a?(Hash)
120
+
121
+ scripts = job["script"] || job[:script]
122
+ case scripts
123
+ when Array
124
+ scripts.filter_map { |script| normalize_command(script) }
125
+ when String
126
+ [normalize_command(scripts)].compact
127
+ else
128
+ []
129
+ end
130
+ end
131
+ end
132
+
133
+ def extract_jenkins_commands(path)
134
+ content = File.read(path)
135
+ commands = content.scan(/\bsh\s+["']([^"']+)["']/).flatten
136
+ commands.filter_map { |command| normalize_command(command) }
137
+ rescue Errno::ENOENT
138
+ []
139
+ end
140
+
141
+ def safe_yaml_load(path)
142
+ YAML.safe_load(File.read(path), aliases: true)
143
+ rescue Psych::SyntaxError, Errno::ENOENT
144
+ nil
145
+ end
146
+
147
+ def pick_command(commands)
148
+ commands.find { |command| yield(command) }
149
+ end
150
+
151
+ def normalize_command(value)
152
+ return nil unless value
153
+
154
+ value.to_s.strip.gsub(/\s+/, " ")
155
+ end
156
+
157
+ def test_command?(command)
158
+ command.match?(/\b(rspec|minitest|pytest|go test|cargo test|npm test|yarn test|pnpm test|rake test|bundle exec rspec|bundle exec rake spec)\b/i)
159
+ end
160
+
161
+ def lint_command?(command)
162
+ command.match?(/\b(rubocop|eslint|prettier|standardrb|lint)\b/i)
163
+ end
164
+
165
+ def deploy_command?(command)
166
+ command.match?(/\b(deploy|kubectl|helm|terraform apply|cap\s)\b/i)
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Spurline
7
+ module Cartographer
8
+ module Analyzers
9
+ class Dotfiles < Analyzer
10
+ RUBOCOP_FILES = [".rubocop.yml"].freeze
11
+ ESLINT_FILES = %w[
12
+ .eslintrc
13
+ .eslintrc.json
14
+ .eslintrc.yml
15
+ .eslintrc.yaml
16
+ .eslintrc.js
17
+ ].freeze
18
+ PRETTIER_FILES = %w[
19
+ .prettierrc
20
+ .prettierrc.json
21
+ .prettierrc.yml
22
+ .prettierrc.yaml
23
+ ].freeze
24
+
25
+ def analyze
26
+ style_configs = {}
27
+ env_vars = parse_env_example
28
+
29
+ rubocop_file = RUBOCOP_FILES.find { |path| file_exists?(path) }
30
+ style_configs[:rubocop] = parse_yaml_keys(rubocop_file) if rubocop_file
31
+
32
+ eslint_file = ESLINT_FILES.find { |path| file_exists?(path) }
33
+ style_configs[:eslint] = parse_config_keys(eslint_file) if eslint_file
34
+
35
+ prettier_file = PRETTIER_FILES.find { |path| file_exists?(path) }
36
+ style_configs[:prettier] = parse_config_keys(prettier_file) if prettier_file
37
+
38
+ style_configs[:editorconfig] = parse_editorconfig_keys if file_exists?(".editorconfig")
39
+
40
+ runtime_versions = {}
41
+ nvmrc = read_file(".nvmrc")&.strip
42
+ runtime_versions[:node] = nvmrc if nvmrc && !nvmrc.empty?
43
+ runtime_versions.merge!(parse_tool_versions)
44
+
45
+ @findings = {
46
+ environment_vars_required: env_vars,
47
+ metadata: {
48
+ dotfiles: {
49
+ style_configs: style_configs,
50
+ runtime_versions: runtime_versions,
51
+ },
52
+ },
53
+ }
54
+ end
55
+
56
+ def confidence
57
+ has_dotfiles = findings.dig(:metadata, :dotfiles, :style_configs)&.any?
58
+ has_dotfiles ? 0.9 : 0.6
59
+ end
60
+
61
+ private
62
+
63
+ def parse_env_example
64
+ content = read_file(".env.example")
65
+ return [] unless content
66
+
67
+ content.each_line.filter_map do |line|
68
+ match = line.match(/^\s*([A-Z][A-Z0-9_]*)\s*=/)
69
+ match&.captures&.first
70
+ end.uniq.sort
71
+ end
72
+
73
+ def parse_editorconfig_keys
74
+ content = read_file(".editorconfig")
75
+ return [] unless content
76
+
77
+ content.each_line.filter_map do |line|
78
+ stripped = line.strip
79
+ next if stripped.empty? || stripped.start_with?("#", ";", "[")
80
+
81
+ stripped.split("=").first&.strip
82
+ end.uniq.sort
83
+ end
84
+
85
+ def parse_tool_versions
86
+ content = read_file(".tool-versions")
87
+ return {} unless content
88
+
89
+ content.each_line.each_with_object({}) do |line, hash|
90
+ stripped = line.strip
91
+ next if stripped.empty? || stripped.start_with?("#")
92
+
93
+ tool, version = stripped.split(/\s+/, 2)
94
+ next unless tool && version
95
+
96
+ hash[tool.to_sym] = version.strip
97
+ end
98
+ end
99
+
100
+ def parse_config_keys(relative_path)
101
+ return [] unless relative_path
102
+
103
+ if relative_path.end_with?(".json") || relative_path == ".eslintrc" || relative_path == ".prettierrc"
104
+ parse_json_keys(relative_path)
105
+ elsif relative_path.end_with?(".yml") || relative_path.end_with?(".yaml")
106
+ parse_yaml_keys(relative_path)
107
+ else
108
+ ["config_present"]
109
+ end
110
+ end
111
+
112
+ def parse_yaml_keys(relative_path)
113
+ return [] unless relative_path
114
+
115
+ payload = YAML.safe_load(read_file(relative_path), aliases: true)
116
+ return [] unless payload.is_a?(Hash)
117
+
118
+ payload.keys.map(&:to_s).sort
119
+ rescue Psych::SyntaxError, NoMethodError
120
+ []
121
+ end
122
+
123
+ def parse_json_keys(relative_path)
124
+ payload = JSON.parse(read_file(relative_path))
125
+ return [] unless payload.is_a?(Hash)
126
+
127
+ payload.keys.map(&:to_s).sort
128
+ rescue JSON::ParserError, NoMethodError
129
+ []
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end