spurline-test 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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/lib/spurline/adapters/base.rb +17 -0
  3. data/lib/spurline/adapters/claude.rb +208 -0
  4. data/lib/spurline/adapters/open_ai.rb +213 -0
  5. data/lib/spurline/adapters/registry.rb +33 -0
  6. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  7. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  8. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  9. data/lib/spurline/agent.rb +433 -0
  10. data/lib/spurline/audit/log.rb +156 -0
  11. data/lib/spurline/audit/secret_filter.rb +121 -0
  12. data/lib/spurline/base.rb +130 -0
  13. data/lib/spurline/cartographer/analyzer.rb +71 -0
  14. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  15. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  16. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  17. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  18. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  19. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  20. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  21. data/lib/spurline/cartographer/runner.rb +88 -0
  22. data/lib/spurline/cartographer.rb +6 -0
  23. data/lib/spurline/channels/base.rb +41 -0
  24. data/lib/spurline/channels/event.rb +136 -0
  25. data/lib/spurline/channels/github.rb +205 -0
  26. data/lib/spurline/channels/router.rb +103 -0
  27. data/lib/spurline/cli/check.rb +88 -0
  28. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  29. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  30. data/lib/spurline/cli/checks/base.rb +35 -0
  31. data/lib/spurline/cli/checks/credentials.rb +43 -0
  32. data/lib/spurline/cli/checks/permissions.rb +22 -0
  33. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  34. data/lib/spurline/cli/checks/session_store.rb +97 -0
  35. data/lib/spurline/cli/console.rb +73 -0
  36. data/lib/spurline/cli/credentials.rb +181 -0
  37. data/lib/spurline/cli/generators/agent.rb +123 -0
  38. data/lib/spurline/cli/generators/migration.rb +62 -0
  39. data/lib/spurline/cli/generators/project.rb +331 -0
  40. data/lib/spurline/cli/generators/tool.rb +98 -0
  41. data/lib/spurline/cli/router.rb +121 -0
  42. data/lib/spurline/configuration.rb +23 -0
  43. data/lib/spurline/dsl/guardrails.rb +108 -0
  44. data/lib/spurline/dsl/hooks.rb +51 -0
  45. data/lib/spurline/dsl/memory.rb +39 -0
  46. data/lib/spurline/dsl/model.rb +23 -0
  47. data/lib/spurline/dsl/persona.rb +74 -0
  48. data/lib/spurline/dsl/suspend_until.rb +53 -0
  49. data/lib/spurline/dsl/tools.rb +176 -0
  50. data/lib/spurline/errors.rb +109 -0
  51. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  52. data/lib/spurline/lifecycle/runner.rb +456 -0
  53. data/lib/spurline/lifecycle/states.rb +47 -0
  54. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  55. data/lib/spurline/memory/context_assembler.rb +100 -0
  56. data/lib/spurline/memory/embedder/base.rb +17 -0
  57. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  58. data/lib/spurline/memory/episode.rb +56 -0
  59. data/lib/spurline/memory/episodic_store.rb +147 -0
  60. data/lib/spurline/memory/long_term/base.rb +22 -0
  61. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  62. data/lib/spurline/memory/manager.rb +147 -0
  63. data/lib/spurline/memory/short_term.rb +57 -0
  64. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  65. data/lib/spurline/orchestration/judge.rb +109 -0
  66. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  67. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  68. data/lib/spurline/orchestration/ledger.rb +339 -0
  69. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  70. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  71. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  72. data/lib/spurline/persona/base.rb +42 -0
  73. data/lib/spurline/persona/registry.rb +42 -0
  74. data/lib/spurline/secrets/resolver.rb +65 -0
  75. data/lib/spurline/secrets/vault.rb +42 -0
  76. data/lib/spurline/security/content.rb +76 -0
  77. data/lib/spurline/security/context_pipeline.rb +58 -0
  78. data/lib/spurline/security/gates/base.rb +36 -0
  79. data/lib/spurline/security/gates/operator_config.rb +22 -0
  80. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  81. data/lib/spurline/security/gates/tool_result.rb +23 -0
  82. data/lib/spurline/security/gates/user_input.rb +22 -0
  83. data/lib/spurline/security/injection_scanner.rb +109 -0
  84. data/lib/spurline/security/pii_filter.rb +104 -0
  85. data/lib/spurline/session/resumption.rb +36 -0
  86. data/lib/spurline/session/serializer.rb +169 -0
  87. data/lib/spurline/session/session.rb +154 -0
  88. data/lib/spurline/session/store/base.rb +27 -0
  89. data/lib/spurline/session/store/memory.rb +45 -0
  90. data/lib/spurline/session/store/postgres.rb +123 -0
  91. data/lib/spurline/session/store/sqlite.rb +139 -0
  92. data/lib/spurline/session/suspension.rb +93 -0
  93. data/lib/spurline/session/turn.rb +98 -0
  94. data/lib/spurline/spur.rb +213 -0
  95. data/lib/spurline/streaming/buffer.rb +77 -0
  96. data/lib/spurline/streaming/chunk.rb +62 -0
  97. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  98. data/lib/spurline/testing.rb +245 -0
  99. data/lib/spurline/toolkit.rb +110 -0
  100. data/lib/spurline/tools/base.rb +209 -0
  101. data/lib/spurline/tools/idempotency.rb +220 -0
  102. data/lib/spurline/tools/permissions.rb +44 -0
  103. data/lib/spurline/tools/registry.rb +43 -0
  104. data/lib/spurline/tools/runner.rb +255 -0
  105. data/lib/spurline/tools/scope.rb +309 -0
  106. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  107. data/lib/spurline/version.rb +5 -0
  108. data/lib/spurline.rb +56 -0
  109. metadata +160 -0
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Spurline
6
+ module Cartographer
7
+ module Analyzers
8
+ class Manifests < Analyzer
9
+ WEB_FRAMEWORKS = {
10
+ rails: "rails",
11
+ sinatra: "sinatra",
12
+ express: "express",
13
+ django: "django",
14
+ }.freeze
15
+
16
+ TEST_FRAMEWORKS = {
17
+ rspec: %w[rspec rspec-rails],
18
+ minitest: %w[minitest],
19
+ jest: %w[jest],
20
+ pytest: %w[pytest],
21
+ }.freeze
22
+
23
+ LINTERS = {
24
+ rubocop: "rubocop",
25
+ eslint: "eslint",
26
+ prettier: "prettier",
27
+ }.freeze
28
+
29
+ def analyze
30
+ gemfile = read_file("Gemfile")
31
+ gemfile_lock = read_file("Gemfile.lock")
32
+ package = parse_json_file("package.json")
33
+ pyproject = read_file("pyproject.toml")
34
+
35
+ frameworks = {}
36
+
37
+ web_name, web_version = detect_web_framework(
38
+ gemfile: gemfile,
39
+ gemfile_lock: gemfile_lock,
40
+ package: package,
41
+ pyproject: pyproject
42
+ )
43
+ frameworks[:web] = { name: web_name, version: web_version } if web_name
44
+
45
+ test_framework = detect_test_framework(
46
+ gemfile: gemfile,
47
+ gemfile_lock: gemfile_lock,
48
+ package: package,
49
+ pyproject: pyproject
50
+ )
51
+ frameworks[:test] = test_framework if test_framework
52
+
53
+ linters = detect_linters(
54
+ gemfile: gemfile,
55
+ gemfile_lock: gemfile_lock,
56
+ package: package,
57
+ pyproject: pyproject
58
+ )
59
+ frameworks[:linter] = linters.length == 1 ? linters.first : linters unless linters.empty?
60
+
61
+ ruby_version = detect_ruby_version(gemfile)
62
+ node_version = detect_node_version(package)
63
+
64
+ parsed_files = []
65
+ parsed_files << "Gemfile" if gemfile
66
+ parsed_files << "Gemfile.lock" if gemfile_lock
67
+ parsed_files << "package.json" if package
68
+ parsed_files << "pyproject.toml" if pyproject
69
+ parsed_files << ".ruby-version" if file_exists?(".ruby-version")
70
+ parsed_files << ".node-version" if file_exists?(".node-version")
71
+ parsed_files << ".python-version" if file_exists?(".python-version")
72
+
73
+ result = {
74
+ frameworks: frameworks,
75
+ metadata: {
76
+ manifests: {
77
+ parsed_files: parsed_files,
78
+ },
79
+ },
80
+ }
81
+ result[:ruby_version] = ruby_version if ruby_version
82
+ result[:node_version] = node_version if node_version
83
+
84
+ @findings = result
85
+ end
86
+
87
+ def confidence
88
+ parsed_count = findings.dig(:metadata, :manifests, :parsed_files)&.length.to_i
89
+ parsed_count.zero? ? 0.6 : 0.95
90
+ end
91
+
92
+ private
93
+
94
+ def detect_web_framework(gemfile:, gemfile_lock:, package:, pyproject:)
95
+ return [:rails, gem_version("rails", gemfile, gemfile_lock, package)] if dependency_present?("rails", gemfile, gemfile_lock, package, pyproject)
96
+ return [:sinatra, gem_version("sinatra", gemfile, gemfile_lock, package)] if dependency_present?("sinatra", gemfile, gemfile_lock, package, pyproject)
97
+ return [:express, package_version("express", package)] if dependency_present?("express", gemfile, gemfile_lock, package, pyproject)
98
+ return [:django, python_version("django", pyproject)] if dependency_present?("django", gemfile, gemfile_lock, package, pyproject)
99
+
100
+ [nil, nil]
101
+ end
102
+
103
+ def detect_test_framework(gemfile:, gemfile_lock:, package:, pyproject:)
104
+ TEST_FRAMEWORKS.each do |key, package_names|
105
+ return key if package_names.any? { |name| dependency_present?(name, gemfile, gemfile_lock, package, pyproject) }
106
+ end
107
+
108
+ nil
109
+ end
110
+
111
+ def detect_linters(gemfile:, gemfile_lock:, package:, pyproject:)
112
+ LINTERS.each_with_object([]) do |(key, package_name), list|
113
+ list << key if dependency_present?(package_name, gemfile, gemfile_lock, package, pyproject)
114
+ end
115
+ end
116
+
117
+ def detect_ruby_version(gemfile)
118
+ version_file = read_file(".ruby-version")&.strip
119
+ return version_file unless version_file.to_s.empty?
120
+
121
+ gemfile&.match(/^\s*ruby\s+["']([^"']+)["']/)&.captures&.first
122
+ end
123
+
124
+ def detect_node_version(package)
125
+ version_file = read_file(".node-version")&.strip
126
+ return version_file unless version_file.to_s.empty?
127
+
128
+ nvmrc_version = read_file(".nvmrc")&.strip
129
+ return nvmrc_version unless nvmrc_version.to_s.empty?
130
+
131
+ package&.dig("engines", "node")
132
+ end
133
+
134
+ def dependency_present?(name, gemfile, gemfile_lock, package, pyproject)
135
+ gem_declared?(name, gemfile) ||
136
+ gem_locked?(name, gemfile_lock) ||
137
+ package_dependency?(name, package) ||
138
+ python_dependency?(name, pyproject)
139
+ end
140
+
141
+ def gem_declared?(name, gemfile)
142
+ return false unless gemfile
143
+
144
+ gemfile.match?(/^\s*gem\s+["']#{Regexp.escape(name)}["']/)
145
+ end
146
+
147
+ def gem_locked?(name, gemfile_lock)
148
+ return false unless gemfile_lock
149
+
150
+ gemfile_lock.match?(/^\s{4}#{Regexp.escape(name)}\s+\(/)
151
+ end
152
+
153
+ def gem_version(name, gemfile, gemfile_lock, package)
154
+ version = gemfile_lock&.match(/^\s{4}#{Regexp.escape(name)}\s+\(([^)]+)\)/)&.captures&.first
155
+ return version if version
156
+
157
+ declared = gem_declared_version(name, gemfile)
158
+ return declared if declared
159
+
160
+ package_version(name, package)
161
+ end
162
+
163
+ def gem_declared_version(name, gemfile)
164
+ return nil unless gemfile
165
+
166
+ line = gemfile.each_line.find { |text| text.match?(/^\s*gem\s+["']#{Regexp.escape(name)}["']/) }
167
+ return nil unless line
168
+
169
+ match = line.match(/^\s*gem\s+["']#{Regexp.escape(name)}["']\s*,\s*["']([^"']+)["']/)
170
+ return nil unless match
171
+
172
+ normalize_version_requirement(match[1])
173
+ end
174
+
175
+ def normalize_version_requirement(value)
176
+ token = value.to_s.split(",").first.to_s.strip
177
+ token = token.sub(/\A[~><=\s]*/, "")
178
+ token.empty? ? nil : token
179
+ end
180
+
181
+ def package_dependency?(name, package)
182
+ return false unless package.is_a?(Hash)
183
+
184
+ package.key?("dependencies") && package["dependencies"].is_a?(Hash) && package["dependencies"].key?(name) ||
185
+ package.key?("devDependencies") && package["devDependencies"].is_a?(Hash) && package["devDependencies"].key?(name)
186
+ end
187
+
188
+ def package_version(name, package)
189
+ return nil unless package.is_a?(Hash)
190
+
191
+ package.dig("dependencies", name) || package.dig("devDependencies", name)
192
+ end
193
+
194
+ def python_dependency?(name, pyproject)
195
+ return false unless pyproject
196
+
197
+ pyproject.match?(/#{Regexp.escape(name)}\s*(?:[<>=~!]|$)/i)
198
+ end
199
+
200
+ def python_version(name, pyproject)
201
+ return nil unless pyproject
202
+
203
+ pyproject.match(/#{Regexp.escape(name)}\s*([<>=~!].+?)?(?:\n|$)/i)&.captures&.first&.strip
204
+ end
205
+
206
+ def parse_json_file(relative_path)
207
+ content = read_file(relative_path)
208
+ return nil unless content
209
+
210
+ JSON.parse(content)
211
+ rescue JSON::ParserError
212
+ nil
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "find"
4
+ require "json"
5
+
6
+ module Spurline
7
+ module Cartographer
8
+ module Analyzers
9
+ class SecurityScan < Analyzer
10
+ SECRET_PATTERNS = {
11
+ openai_key: /\bsk-[A-Za-z0-9]{20,}\b/,
12
+ aws_access_key_id: /\bAKIA[0-9A-Z]{16}\b/,
13
+ github_token: /\bghp_[A-Za-z0-9]{36}\b/,
14
+ }.freeze
15
+ GENERIC_ASSIGNMENT_PATTERN = /\b(api[_-]?key|token|password|secret)\b\s*[:=]\s*["']([^"']{16,})["']/i.freeze
16
+
17
+ CURATED_SUSPICIOUS_DEPENDENCIES = %w[
18
+ pymafka
19
+ requests-darwin
20
+ osxroot
21
+ colourfool
22
+ ].freeze
23
+
24
+ MAX_SCAN_BYTES = 512_000
25
+
26
+ def analyze
27
+ findings = []
28
+ findings.concat(scan_sensitive_filenames)
29
+ findings.concat(scan_secret_patterns)
30
+ findings.concat(scan_suspicious_dependencies)
31
+
32
+ @findings = {
33
+ security_findings: findings,
34
+ }
35
+ end
36
+
37
+ def confidence
38
+ 0.9
39
+ end
40
+
41
+ private
42
+
43
+ def scan_sensitive_filenames
44
+ each_candidate_file.each_with_object([]) do |(path, rel_path), findings|
45
+ basename = File.basename(path)
46
+
47
+ if rel_path == ".env" || rel_path.end_with?("/.env")
48
+ findings << finding(
49
+ type: :sensitive_file,
50
+ severity: :high,
51
+ file: rel_path,
52
+ detail: "Environment file is committed."
53
+ )
54
+ end
55
+
56
+ if rel_path == "config/credentials.yml" || rel_path == "credentials.yml"
57
+ findings << finding(
58
+ type: :sensitive_file,
59
+ severity: :high,
60
+ file: rel_path,
61
+ detail: "Credentials file is committed."
62
+ )
63
+ end
64
+
65
+ if basename.match?(/\.(pem|key)\z/i)
66
+ findings << finding(
67
+ type: :sensitive_file,
68
+ severity: :high,
69
+ file: rel_path,
70
+ detail: "Private key material appears committed (#{basename})."
71
+ )
72
+ end
73
+ end
74
+ end
75
+
76
+ def scan_secret_patterns
77
+ each_candidate_file.each_with_object([]) do |(path, rel_path), findings|
78
+ next if binary_file?(path)
79
+
80
+ content = read_limited(path)
81
+ next if content.nil? || content.empty?
82
+
83
+ SECRET_PATTERNS.each do |name, pattern|
84
+ next unless content.match?(pattern)
85
+
86
+ findings << finding(
87
+ type: :hardcoded_secret,
88
+ severity: :high,
89
+ file: rel_path,
90
+ detail: "Matched #{name} pattern."
91
+ )
92
+ end
93
+
94
+ generic_secret_assignment_details(content).each do |detail|
95
+ findings << finding(
96
+ type: :hardcoded_secret,
97
+ severity: :high,
98
+ file: rel_path,
99
+ detail: detail
100
+ )
101
+ end
102
+ end
103
+ end
104
+
105
+ def scan_suspicious_dependencies
106
+ findings = []
107
+
108
+ package = parse_json_file("package.json")
109
+ if package
110
+ deps = [package["dependencies"], package["devDependencies"]].compact
111
+ .select { |value| value.is_a?(Hash) }
112
+ .flat_map(&:keys)
113
+ .uniq
114
+
115
+ deps.each do |dependency|
116
+ next unless CURATED_SUSPICIOUS_DEPENDENCIES.include?(dependency)
117
+
118
+ findings << finding(
119
+ type: :suspicious_dependency,
120
+ severity: :medium,
121
+ file: "package.json",
122
+ detail: "Dependency '#{dependency}' is in the curated suspicious list."
123
+ )
124
+ end
125
+ end
126
+
127
+ gemfile = read_file("Gemfile")
128
+ if gemfile
129
+ gemfile.scan(/^\s*gem\s+["']([^"']+)["']/).flatten.each do |dependency|
130
+ next unless CURATED_SUSPICIOUS_DEPENDENCIES.include?(dependency)
131
+
132
+ findings << finding(
133
+ type: :suspicious_dependency,
134
+ severity: :medium,
135
+ file: "Gemfile",
136
+ detail: "Gem '#{dependency}' is in the curated suspicious list."
137
+ )
138
+ end
139
+ end
140
+
141
+ findings
142
+ end
143
+
144
+ def each_candidate_file
145
+ files = []
146
+
147
+ Find.find(repo_path) do |path|
148
+ rel_path = relative_path(path)
149
+ rel_path = "." if rel_path.empty?
150
+
151
+ if File.directory?(path)
152
+ if rel_path != "." && excluded_relative_path?(rel_path)
153
+ Find.prune
154
+ else
155
+ next
156
+ end
157
+ end
158
+
159
+ next if excluded_relative_path?(rel_path)
160
+ next unless File.file?(path)
161
+
162
+ files << [path, rel_path]
163
+ end
164
+
165
+ files
166
+ end
167
+
168
+ def parse_json_file(relative_path)
169
+ content = read_file(relative_path)
170
+ return nil unless content
171
+
172
+ JSON.parse(content)
173
+ rescue JSON::ParserError
174
+ nil
175
+ end
176
+
177
+ def read_limited(path)
178
+ File.open(path, "rb") { |file| file.read(MAX_SCAN_BYTES) }
179
+ &.force_encoding(Encoding::UTF_8)
180
+ &.scrub
181
+ rescue Errno::ENOENT, ArgumentError
182
+ nil
183
+ end
184
+
185
+ def binary_file?(path)
186
+ sample = File.open(path, "rb") { |file| file.read(1024) }
187
+ return false unless sample
188
+
189
+ sample.include?("\x00")
190
+ rescue Errno::ENOENT
191
+ false
192
+ end
193
+
194
+ def finding(type:, severity:, file:, detail:)
195
+ {
196
+ type: type,
197
+ severity: severity,
198
+ file: file,
199
+ detail: detail,
200
+ }
201
+ end
202
+
203
+ def generic_secret_assignment_details(content)
204
+ content.scan(GENERIC_ASSIGNMENT_PATTERN).filter_map do |(name, value)|
205
+ next if benign_secret_value?(value)
206
+
207
+ "Matched generic_secret_assignment pattern for #{name.downcase}."
208
+ end.uniq
209
+ end
210
+
211
+ def benign_secret_value?(value)
212
+ normalized = value.to_s.strip
213
+ return true if normalized.empty?
214
+ return true if normalized.match?(/\A\[REDACTED/i)
215
+ return true if normalized.match?(/\A(redacted|test|example|dummy|fake|placeholder|changeme|replace_me)/i)
216
+ return true if normalized.include?("user@example.com")
217
+
218
+ false
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module Spurline
7
+ module Cartographer
8
+ # Immutable, serializable analysis output for a repository.
9
+ class RepoProfile
10
+ CURRENT_VERSION = "1.0"
11
+
12
+ attr_reader :version, :analyzed_at, :repo_path,
13
+ :languages, :frameworks, :ruby_version, :node_version,
14
+ :ci, :entry_points, :environment_vars_required,
15
+ :security_findings, :confidence, :metadata
16
+
17
+ def initialize(**attrs)
18
+ @version = CURRENT_VERSION
19
+ @analyzed_at = normalize_time(attrs.fetch(:analyzed_at, Time.now.utc.iso8601))
20
+ @repo_path = attrs.fetch(:repo_path)
21
+ @languages = deep_copy(attrs.fetch(:languages, {}))
22
+ @frameworks = deep_copy(attrs.fetch(:frameworks, {}))
23
+ @ruby_version = attrs.fetch(:ruby_version, nil)
24
+ @node_version = attrs.fetch(:node_version, nil)
25
+ @ci = deep_copy(attrs.fetch(:ci, {}))
26
+ @entry_points = deep_copy(attrs.fetch(:entry_points, {}))
27
+ @environment_vars_required = deep_copy(attrs.fetch(:environment_vars_required, []))
28
+ @security_findings = deep_copy(attrs.fetch(:security_findings, []))
29
+ @confidence = deep_copy(attrs.fetch(:confidence, {}))
30
+ @metadata = deep_copy(attrs.fetch(:metadata, {}))
31
+
32
+ deep_freeze(@languages)
33
+ deep_freeze(@frameworks)
34
+ deep_freeze(@ci)
35
+ deep_freeze(@entry_points)
36
+ deep_freeze(@environment_vars_required)
37
+ deep_freeze(@security_findings)
38
+ deep_freeze(@confidence)
39
+ deep_freeze(@metadata)
40
+ freeze
41
+ end
42
+
43
+ def to_h
44
+ {
45
+ version: version,
46
+ analyzed_at: analyzed_at,
47
+ repo_path: repo_path,
48
+ languages: deep_copy(languages),
49
+ frameworks: deep_copy(frameworks),
50
+ ruby_version: ruby_version,
51
+ node_version: node_version,
52
+ ci: deep_copy(ci),
53
+ entry_points: deep_copy(entry_points),
54
+ environment_vars_required: deep_copy(environment_vars_required),
55
+ security_findings: deep_copy(security_findings),
56
+ confidence: deep_copy(confidence),
57
+ metadata: deep_copy(metadata),
58
+ }
59
+ end
60
+
61
+ def self.from_h(hash)
62
+ data = deep_symbolize(hash || {})
63
+ new(
64
+ analyzed_at: data[:analyzed_at],
65
+ repo_path: data.fetch(:repo_path),
66
+ languages: data[:languages] || {},
67
+ frameworks: data[:frameworks] || {},
68
+ ruby_version: data[:ruby_version],
69
+ node_version: data[:node_version],
70
+ ci: data[:ci] || {},
71
+ entry_points: data[:entry_points] || {},
72
+ environment_vars_required: data[:environment_vars_required] || [],
73
+ security_findings: data[:security_findings] || [],
74
+ confidence: data[:confidence] || {},
75
+ metadata: data[:metadata] || {}
76
+ )
77
+ end
78
+
79
+ def to_json(*)
80
+ JSON.generate(to_h)
81
+ end
82
+
83
+ def secure?
84
+ security_findings.empty?
85
+ end
86
+
87
+ private
88
+
89
+ def normalize_time(value)
90
+ return value.utc.iso8601 if value.respond_to?(:utc) && value.respond_to?(:iso8601)
91
+
92
+ value.to_s
93
+ end
94
+
95
+ def deep_copy(value)
96
+ case value
97
+ when Hash
98
+ value.each_with_object({}) do |(key, item), copy|
99
+ copy[key] = deep_copy(item)
100
+ end
101
+ when Array
102
+ value.map { |item| deep_copy(item) }
103
+ else
104
+ value
105
+ end
106
+ end
107
+
108
+ def deep_freeze(value)
109
+ case value
110
+ when Hash
111
+ value.each do |key, item|
112
+ deep_freeze(key)
113
+ deep_freeze(item)
114
+ end
115
+ when Array
116
+ value.each { |item| deep_freeze(item) }
117
+ end
118
+
119
+ value.freeze
120
+ end
121
+
122
+ class << self
123
+ private
124
+
125
+ def deep_symbolize(value)
126
+ case value
127
+ when Hash
128
+ value.each_with_object({}) do |(key, item), hash|
129
+ hash[key.to_sym] = deep_symbolize(item)
130
+ end
131
+ when Array
132
+ value.map { |item| deep_symbolize(item) }
133
+ else
134
+ value
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Cartographer
5
+ class Runner
6
+ ANALYZERS = [
7
+ Analyzers::FileSignatures,
8
+ Analyzers::Manifests,
9
+ Analyzers::CIConfig,
10
+ Analyzers::Dotfiles,
11
+ Analyzers::EntryPoints,
12
+ Analyzers::SecurityScan,
13
+ ].freeze
14
+
15
+ # ASYNC-READY:
16
+ def analyze(repo_path:, scheduler: Spurline::Adapters::Scheduler::Sync.new)
17
+ expanded_path = File.expand_path(repo_path)
18
+ validate_path!(expanded_path)
19
+
20
+ results = {}
21
+ confidences = {}
22
+ active_scheduler = scheduler.is_a?(Class) ? scheduler.new : scheduler
23
+
24
+ ANALYZERS.each do |klass|
25
+ analyzer = klass.new(repo_path: expanded_path)
26
+ layer_result = active_scheduler.run { analyzer.analyze }
27
+
28
+ unless layer_result.is_a?(Hash)
29
+ raise Spurline::AnalyzerError,
30
+ "#{klass.name} returned #{layer_result.class} instead of Hash"
31
+ end
32
+
33
+ results = deep_merge(results, layer_result)
34
+ confidences[analyzer_key(klass)] = analyzer.confidence
35
+ rescue StandardError => e
36
+ confidences[analyzer_key(klass)] = 0.0
37
+ results[:metadata] ||= {}
38
+ (results[:metadata][:analyzer_errors] ||= []) << {
39
+ analyzer: klass.name,
40
+ error: e.message,
41
+ }
42
+ end
43
+
44
+ results = deep_merge(results, confidence: build_confidence(confidences))
45
+
46
+ RepoProfile.new(repo_path: expanded_path, **results)
47
+ end
48
+
49
+ private
50
+
51
+ def validate_path!(path)
52
+ return if File.directory?(path)
53
+
54
+ raise Spurline::CartographerAccessError,
55
+ "Repository path '#{path}' does not exist or is not a directory. " \
56
+ "Provide an absolute path to a valid repository."
57
+ end
58
+
59
+ def analyzer_key(klass)
60
+ name = klass.name || "AnonymousAnalyzer#{klass.object_id}"
61
+ name = name.split("::").last
62
+ name = name.gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
63
+ name = name.gsub(/([a-z\\d])([A-Z])/, "\\1_\\2")
64
+ name.downcase.to_sym
65
+ end
66
+
67
+ def build_confidence(layer_confidences)
68
+ scores = layer_confidences.values
69
+ {
70
+ overall: scores.empty? ? 0.0 : (scores.sum / scores.size).round(2),
71
+ per_layer: layer_confidences,
72
+ }
73
+ end
74
+
75
+ def deep_merge(left, right)
76
+ left.merge(right) do |_key, left_value, right_value|
77
+ if left_value.is_a?(Hash) && right_value.is_a?(Hash)
78
+ deep_merge(left_value, right_value)
79
+ elsif left_value.is_a?(Array) && right_value.is_a?(Array)
80
+ (left_value + right_value).uniq
81
+ else
82
+ right_value
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Cartographer
5
+ end
6
+ end