reanimator 0.1.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 (114) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +207 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +483 -0
  5. data/SECURITY.md +32 -0
  6. data/exe/reanimator +6 -0
  7. data/lib/reanimator/acceptance_criterion.rb +75 -0
  8. data/lib/reanimator/agent_connection.rb +62 -0
  9. data/lib/reanimator/agent_profile.rb +51 -0
  10. data/lib/reanimator/agents/adapter.rb +49 -0
  11. data/lib/reanimator/agents/capability.rb +29 -0
  12. data/lib/reanimator/agents/constraints.rb +57 -0
  13. data/lib/reanimator/agents/hosted.rb +38 -0
  14. data/lib/reanimator/agents/local.rb +46 -0
  15. data/lib/reanimator/agents/patch.rb +60 -0
  16. data/lib/reanimator/agents/repair_task.rb +51 -0
  17. data/lib/reanimator/agents/result.rb +42 -0
  18. data/lib/reanimator/agents/strategies/arithmetic_fix.rb +51 -0
  19. data/lib/reanimator/agents/strategies/base.rb +26 -0
  20. data/lib/reanimator/agents.rb +38 -0
  21. data/lib/reanimator/autonomy_level.rb +41 -0
  22. data/lib/reanimator/baseline.rb +106 -0
  23. data/lib/reanimator/baseline_runner.rb +81 -0
  24. data/lib/reanimator/bounded_command.rb +87 -0
  25. data/lib/reanimator/classifier.rb +89 -0
  26. data/lib/reanimator/cli.rb +273 -0
  27. data/lib/reanimator/cohort.rb +61 -0
  28. data/lib/reanimator/commissioning/fixture.rb +69 -0
  29. data/lib/reanimator/commissioning/report.rb +45 -0
  30. data/lib/reanimator/commissioning/test.rb +145 -0
  31. data/lib/reanimator/confidence.rb +66 -0
  32. data/lib/reanimator/configuration.rb +35 -0
  33. data/lib/reanimator/contract_file.rb +116 -0
  34. data/lib/reanimator/contract_snapshot.rb +188 -0
  35. data/lib/reanimator/criteria_stack.rb +91 -0
  36. data/lib/reanimator/criterion_result.rb +70 -0
  37. data/lib/reanimator/detectors/base.rb +49 -0
  38. data/lib/reanimator/detectors/bundler.rb +68 -0
  39. data/lib/reanimator/detectors/commands.rb +112 -0
  40. data/lib/reanimator/detectors/fork.rb +25 -0
  41. data/lib/reanimator/detectors/github_actions.rb +68 -0
  42. data/lib/reanimator/detectors/operational_proof.rb +48 -0
  43. data/lib/reanimator/detectors/rails_app.rb +51 -0
  44. data/lib/reanimator/detectors/relationships.rb +46 -0
  45. data/lib/reanimator/detectors/ruby_gem.rb +40 -0
  46. data/lib/reanimator/detectors/security_controls.rb +36 -0
  47. data/lib/reanimator/discovered_fact.rb +99 -0
  48. data/lib/reanimator/discovery.rb +48 -0
  49. data/lib/reanimator/discovery_result.rb +89 -0
  50. data/lib/reanimator/drift_detector.rb +227 -0
  51. data/lib/reanimator/drift_report.rb +86 -0
  52. data/lib/reanimator/effective_contract.rb +108 -0
  53. data/lib/reanimator/effective_policy_resolver.rb +116 -0
  54. data/lib/reanimator/errors.rb +21 -0
  55. data/lib/reanimator/evaluators/base.rb +55 -0
  56. data/lib/reanimator/evaluators/command.rb +34 -0
  57. data/lib/reanimator/evaluators/documentation_check.rb +25 -0
  58. data/lib/reanimator/evaluators/http_smoke.rb +19 -0
  59. data/lib/reanimator/evaluators/repository_integrity.rb +22 -0
  60. data/lib/reanimator/evaluators/security_evidence.rb +24 -0
  61. data/lib/reanimator/evaluators/support_matrix.rb +25 -0
  62. data/lib/reanimator/evaluators.rb +41 -0
  63. data/lib/reanimator/family/gate.rb +40 -0
  64. data/lib/reanimator/family/gate_result.rb +46 -0
  65. data/lib/reanimator/family/gate_runner.rb +91 -0
  66. data/lib/reanimator/family.rb +89 -0
  67. data/lib/reanimator/family_inference.rb +113 -0
  68. data/lib/reanimator/git.rb +72 -0
  69. data/lib/reanimator/isolation.rb +36 -0
  70. data/lib/reanimator/language_inference.rb +43 -0
  71. data/lib/reanimator/lifecycle_intent.rb +93 -0
  72. data/lib/reanimator/obligation.rb +35 -0
  73. data/lib/reanimator/permission_model.rb +61 -0
  74. data/lib/reanimator/portfolio.rb +68 -0
  75. data/lib/reanimator/portfolio_drift.rb +120 -0
  76. data/lib/reanimator/profile_inference.rb +37 -0
  77. data/lib/reanimator/profiles.rb +110 -0
  78. data/lib/reanimator/proof_of_life.rb +45 -0
  79. data/lib/reanimator/proposed_change.rb +55 -0
  80. data/lib/reanimator/provenance.rb +39 -0
  81. data/lib/reanimator/relationship.rb +62 -0
  82. data/lib/reanimator/repair_run.rb +146 -0
  83. data/lib/reanimator/reports/agents.rb +28 -0
  84. data/lib/reanimator/reports/baseline.rb +73 -0
  85. data/lib/reanimator/reports/commissioning.rb +48 -0
  86. data/lib/reanimator/reports/contract.rb +70 -0
  87. data/lib/reanimator/reports/discovery.rb +38 -0
  88. data/lib/reanimator/reports/drift.rb +63 -0
  89. data/lib/reanimator/reports/families.rb +41 -0
  90. data/lib/reanimator/reports/family_gates.rb +41 -0
  91. data/lib/reanimator/reports/github_check.rb +45 -0
  92. data/lib/reanimator/reports/inventory.rb +37 -0
  93. data/lib/reanimator/reports/permissions.rb +32 -0
  94. data/lib/reanimator/reports/portfolio_baseline.rb +68 -0
  95. data/lib/reanimator/reports/portfolio_drift.rb +107 -0
  96. data/lib/reanimator/reports/run.rb +52 -0
  97. data/lib/reanimator/reports/snapshot.rb +58 -0
  98. data/lib/reanimator/reports.rb +36 -0
  99. data/lib/reanimator/repository.rb +96 -0
  100. data/lib/reanimator/repository_metadata.rb +78 -0
  101. data/lib/reanimator/repository_state.rb +48 -0
  102. data/lib/reanimator/routing/decision.rb +35 -0
  103. data/lib/reanimator/routing/router.rb +122 -0
  104. data/lib/reanimator/run.rb +48 -0
  105. data/lib/reanimator/safety_floor.rb +81 -0
  106. data/lib/reanimator/security_control.rb +87 -0
  107. data/lib/reanimator/security_posture.rb +80 -0
  108. data/lib/reanimator/snapshot_store.rb +78 -0
  109. data/lib/reanimator/task_class.rb +63 -0
  110. data/lib/reanimator/version.rb +5 -0
  111. data/lib/reanimator/vitality_profile.rb +44 -0
  112. data/lib/reanimator/waiver.rb +74 -0
  113. data/lib/reanimator.rb +214 -0
  114. metadata +262 -0
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Reanimator
6
+ # A comparable, serializable picture of what a repository was required to
7
+ # prove at a point in time. Configuration drift (specification section 06) is
8
+ # a comparison between two of these: the contract as it was last recorded,
9
+ # and the contract the current evidence resolves to.
10
+ #
11
+ # It deliberately captures more than the contract alone. "A removed security
12
+ # gate" and "a narrowed runtime matrix" are the section 06 examples of a
13
+ # policy change, and both live in discovered evidence rather than in the
14
+ # declared file, so the snapshot carries them too.
15
+ class ContractSnapshot
16
+ # `taken_at` is normalized to an ISO-8601 string on the way in: a snapshot
17
+ # exists to be written down and read back, so the moment it records reads
18
+ # the same whether it was just taken or loaded from a file.
19
+ attr_reader :repository, :origin, :intent, :profile, :security_posture, :autonomy,
20
+ :protected_paths, :criteria, :waivers, :support_matrix, :security_controls, :taken_at
21
+
22
+ def initialize(repository:, intent:, profile:, security_posture:, autonomy: {},
23
+ protected_paths: [], criteria: [], waivers: [], support_matrix: [],
24
+ security_controls: [], origin: nil, taken_at: nil)
25
+ @repository = immutable(repository)
26
+ @origin = immutable(origin)
27
+ @intent = intent
28
+ @profile = profile
29
+ @security_posture = security_posture
30
+ @autonomy = immutable(autonomy)
31
+ @protected_paths = immutable(protected_paths)
32
+ @criteria = immutable(criteria)
33
+ @waivers = immutable(waivers)
34
+ @support_matrix = immutable(support_matrix)
35
+ @security_controls = immutable(security_controls)
36
+ @taken_at = taken_at.is_a?(Time) ? taken_at.iso8601 : taken_at
37
+ freeze
38
+ end
39
+
40
+ # A snapshot records what was true at a moment, so it must not be editable
41
+ # afterwards - and `freeze` alone does not achieve that, because it leaves
42
+ # the recorded arrays and hashes writable. Copies are frozen rather than
43
+ # the caller's own collections, so recording a contract never freezes the
44
+ # live contract.
45
+ def immutable(value)
46
+ case value
47
+ when Hash then value.transform_values { |entry| immutable(entry) }.freeze
48
+ when Array then value.map { |entry| immutable(entry) }.freeze
49
+ when String then value.frozen? ? value : value.dup.freeze
50
+ else value
51
+ end
52
+ end
53
+ private :immutable
54
+
55
+ # @param contract [EffectiveContract]
56
+ # @return [ContractSnapshot]
57
+ def self.of(contract, taken_at: Time.now)
58
+ discovery = contract.discovery
59
+
60
+ new(
61
+ repository: contract.repository.full_name,
62
+ origin: Git.origin_url(contract.repository.path),
63
+ intent: contract.intent.to_sym,
64
+ profile: contract.profile.id,
65
+ security_posture: contract.security_posture.to_sym,
66
+ autonomy: contract.autonomy,
67
+ protected_paths: contract.protected_paths,
68
+ criteria: contract.criteria.map { |criterion| record_criterion(criterion) },
69
+ waivers: contract.waivers.map { |waiver| record_waiver(waiver) },
70
+ support_matrix: discovered_matrix(discovery),
71
+ security_controls: discovered_controls(discovery),
72
+ taken_at: taken_at
73
+ )
74
+ end
75
+
76
+ def self.record_criterion(criterion)
77
+ {id: criterion.id, evaluator: criterion.evaluator, run: criterion.run, required: criterion.required?}
78
+ end
79
+ private_class_method :record_criterion
80
+
81
+ def self.record_waiver(waiver)
82
+ {target: waiver.target, expires: waiver.expires&.iso8601}
83
+ end
84
+ private_class_method :record_waiver
85
+
86
+ # What CI actually exercises, falling back to what the project declares.
87
+ def self.discovered_matrix(discovery)
88
+ return [] unless discovery
89
+
90
+ Array(discovery.value(:ci_ruby_matrix) || discovery.value(:supported_ruby)).map(&:to_s)
91
+ end
92
+ private_class_method :discovered_matrix
93
+
94
+ def self.discovered_controls(discovery)
95
+ return [] unless discovery
96
+
97
+ discovery.in_category(:security_controls).map(&:key).sort
98
+ end
99
+ private_class_method :discovered_controls
100
+
101
+ # Rebuilds a snapshot from its serialized form, tolerating the string keys
102
+ # a JSON round-trip produces.
103
+ def self.from_h(hash)
104
+ data = symbolize(hash)
105
+ unless data.is_a?(Hash) && data.key?(:repository)
106
+ raise ContractError,
107
+ "That is not a contract snapshot; expected the JSON object written by `reanimator drift --save`."
108
+ end
109
+
110
+ new(
111
+ repository: data[:repository],
112
+ origin: data[:origin],
113
+ intent: data[:intent]&.to_sym,
114
+ profile: data[:profile]&.to_sym,
115
+ security_posture: data[:security_posture]&.to_sym,
116
+ autonomy: (data[:autonomy] || {}).transform_values(&:to_sym),
117
+ protected_paths: Array(data[:protected_paths]).map(&:to_s),
118
+ criteria: Array(data[:criteria]).map { |criterion| restore_criterion(criterion) },
119
+ waivers: Array(data[:waivers]).map { |waiver| restore_waiver(waiver) },
120
+ support_matrix: Array(data[:support_matrix]).map(&:to_s),
121
+ security_controls: Array(data[:security_controls]).map(&:to_sym),
122
+ taken_at: data[:taken_at]
123
+ )
124
+ end
125
+
126
+ def self.restore_criterion(criterion)
127
+ {
128
+ id: criterion[:id]&.to_sym, evaluator: criterion[:evaluator]&.to_sym,
129
+ run: criterion[:run], required: criterion[:required]
130
+ }
131
+ end
132
+ private_class_method :restore_criterion
133
+
134
+ def self.restore_waiver(waiver)
135
+ {target: waiver[:target]&.to_sym, expires: waiver[:expires]}
136
+ end
137
+ private_class_method :restore_waiver
138
+
139
+ def self.load(path)
140
+ from_h(JSON.parse(File.read(path, encoding: "UTF-8")))
141
+ rescue JSON::ParserError => e
142
+ raise ContractError, "#{path} is not a valid contract snapshot: #{e.message}"
143
+ rescue Errno::ENOENT
144
+ raise ContractError, "No contract snapshot at #{path}."
145
+ rescue SystemCallError => e
146
+ raise ContractError, "Could not read the contract snapshot at #{path}: #{e.message}"
147
+ end
148
+
149
+ def self.symbolize(value)
150
+ case value
151
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_sym] = symbolize(v) }
152
+ when Array then value.map { |v| symbolize(v) }
153
+ else value
154
+ end
155
+ end
156
+ private_class_method :symbolize
157
+
158
+ # The criterion recorded under an id, or nil.
159
+ def criterion(id)
160
+ criteria.find { |c| c[:id] == id }
161
+ end
162
+
163
+ def criterion_ids = criteria.map { |c| c[:id] }
164
+
165
+ # Whether a recorded criterion was blocking. Asked rather than dug out of
166
+ # the criterion hash, so callers never have to know its shape.
167
+ def requires?(id) = criterion(id)&.fetch(:required, false) == true
168
+
169
+ def waiver_targets = waivers.map { |w| w[:target] }
170
+
171
+ # When accepted risk for a target runs out, or nil for open-ended.
172
+ def waiver_expiry(target)
173
+ waivers.find { |waiver| waiver[:target] == target }&.[](:expires)
174
+ end
175
+
176
+ def to_h
177
+ {
178
+ repository: repository, origin: origin,
179
+ taken_at: taken_at,
180
+ intent: intent, profile: profile, security_posture: security_posture,
181
+ autonomy: autonomy, protected_paths: protected_paths, criteria: criteria,
182
+ waivers: waivers, support_matrix: support_matrix, security_controls: security_controls
183
+ }
184
+ end
185
+
186
+ def to_json(*args) = JSON.pretty_generate(to_h, *args)
187
+ end
188
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Assembles the acceptance criteria for one repository out of the layers
5
+ # allowed to contribute them (specification section 07). Three layers speak:
6
+ # the vitality profile's defaults, the security posture's obligations, and
7
+ # the owner's declared checks. They are *merged*, not concatenated, so a
8
+ # layer naming an existing criterion id replaces it rather than arguing with
9
+ # it twice in the same report - and the owner's declaration wins.
10
+ #
11
+ # This is deliberately not part of {EffectivePolicyResolver}: resolving the
12
+ # precedence stack and assembling the list of things to prove change for
13
+ # different reasons, and adding a fourth layer should not mean reopening the
14
+ # resolver.
15
+ class CriteriaStack
16
+ def initialize(profile:, posture:, discovery:, declared:)
17
+ @profile = profile
18
+ @posture = posture
19
+ @discovery = discovery
20
+ @declared = declared
21
+ end
22
+
23
+ # @return [Array<AcceptanceCriterion>]
24
+ def resolve
25
+ merge(profile_criteria, security_criteria, declared_criteria)
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :profile, :posture, :discovery, :declared
31
+
32
+ # Keeps each criterion in the position it first appeared, so reports stay
33
+ # stable, while the last layer to claim an id supplies the criterion.
34
+ def merge(*layers)
35
+ layers.flatten.each_with_object({}) { |criterion, merged| merged[criterion.id] = criterion }.values
36
+ end
37
+
38
+ # Profile criteria, with discovered commands substituted in for the
39
+ # matching defaults (section 00: reuse detected checks; surface missing
40
+ # proof).
41
+ def profile_criteria
42
+ commands = discovery.discovered_commands
43
+ profile.resolved_criteria.map do |criterion|
44
+ run = commands[criterion.id]
45
+ run ? with_run(criterion, run) : criterion
46
+ end
47
+ end
48
+
49
+ def with_run(criterion, run)
50
+ AcceptanceCriterion.new(
51
+ id: criterion.id, claim: criterion.claim, evaluator: criterion.evaluator,
52
+ run: run, target: criterion.target, required: criterion.required,
53
+ scope: criterion.scope, freshness: criterion.freshness, owner: criterion.owner,
54
+ risk_class: criterion.risk_class,
55
+ provenance: discovery.command_fact(criterion.id)&.provenance
56
+ )
57
+ end
58
+
59
+ # The security evidence the posture makes binding (section 12). These are
60
+ # policy-owned rather than profile-owned: how much security proof a
61
+ # repository must show is a posture decision, not a question of what shape
62
+ # the project happens to be.
63
+ def security_criteria
64
+ posture.obligations.map do |control_id, required|
65
+ control = SecurityControl.fetch(control_id)
66
+ AcceptanceCriterion.new(
67
+ id: control.criterion_id, claim: control.claim,
68
+ evaluator: :security_evidence, target: control.to_s,
69
+ required: required, owner: "policy"
70
+ )
71
+ end
72
+ end
73
+
74
+ # Owner-declared acceptance checks become criteria owned by the repository.
75
+ # A criterion that edits CI/verifier surfaces is elevated by policy.
76
+ def declared_criteria
77
+ checks = declared&.acceptance&.dig(:checks) || []
78
+ checks.map do |check|
79
+ AcceptanceCriterion.new(
80
+ id: check[:id] || "declared-#{check.object_id}",
81
+ claim: check[:claim] || "Owner-declared check #{check[:id]}.",
82
+ evaluator: check[:evaluator] || :command,
83
+ run: check[:run], target: check[:target],
84
+ required: check.fetch(:required, true),
85
+ owner: "repository",
86
+ risk_class: check[:risk_class] || :normal
87
+ )
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # The outcome of evaluating one acceptance criterion during a baseline. Its
5
+ # status is drawn from the vocabulary section 14 requires a baseline to
6
+ # separate: verified claims, failed claims, missing evidence, skipped
7
+ # boundaries, and accepted risks - plus "planned" for the read-only,
8
+ # non-executing default.
9
+ class CriterionResult
10
+ STATUSES = %i[verified failed missing_evidence skipped accepted_risk planned error].freeze
11
+
12
+ attr_reader :criterion, :status, :detail, :evidence, :duration
13
+
14
+ def initialize(criterion:, status:, detail: nil, evidence: nil, duration: nil)
15
+ @criterion = criterion
16
+ @status = status.to_sym
17
+ unless STATUSES.include?(@status)
18
+ raise UnknownError, "Unknown criterion result status #{status.inspect}."
19
+ end
20
+ @detail = detail
21
+ @evidence = evidence
22
+ @duration = duration
23
+ end
24
+
25
+ def verified? = status == :verified
26
+
27
+ def failed? = status == :failed
28
+
29
+ # A required criterion whose failure has been explicitly accepted via a
30
+ # waiver: not verified, but not blocking either.
31
+ def accepted_risk? = status == :accepted_risk
32
+
33
+ # Whether this result contributes proof (a verified required criterion).
34
+ def proven? = verified?
35
+
36
+ # Whether the criterion is still unproven: no evidence either way yet.
37
+ # An evaluator that errored belongs here too - "we could not tell" is not
38
+ # proof, and leaving it out would let a broken check read as green.
39
+ def unproven? = %i[missing_evidence planned skipped error].include?(status)
40
+
41
+ def required? = criterion.required?
42
+
43
+ # Whether this result is eligible to be waived: a required obligation that
44
+ # is not proven and has not already been accepted.
45
+ def waivable? = required? && !verified? && !accepted_risk?
46
+
47
+ # A copy of this result recorded as accepted risk under the given waiver.
48
+ # The criterion is still not verified - its failure is explicitly accepted.
49
+ #
50
+ # @param waiver [Waiver]
51
+ # @return [CriterionResult]
52
+ def accepted_via(waiver)
53
+ CriterionResult.new(
54
+ criterion: criterion, status: :accepted_risk,
55
+ detail: waiver.acceptance_of(self), evidence: evidence, duration: duration
56
+ )
57
+ end
58
+
59
+ def to_h
60
+ {
61
+ id: criterion.id,
62
+ claim: criterion.claim,
63
+ required: criterion.required?,
64
+ status: status,
65
+ detail: detail,
66
+ duration: duration
67
+ }.compact
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Detectors
5
+ # Base class for every detector. A detector reads a repository through the
6
+ # read-only helpers on {Repository} and returns an array of
7
+ # {DiscoveredFact}s, each carrying its own provenance and confidence. It
8
+ # never decides policy - it only proposes facts for a human to confirm.
9
+ class Base
10
+ # @return [Repository]
11
+ attr_reader :repository
12
+
13
+ def initialize(repository)
14
+ @repository = repository
15
+ end
16
+
17
+ # @return [Array<DiscoveredFact>]
18
+ def call
19
+ detect
20
+ end
21
+
22
+ # @return [Array<DiscoveredFact>]
23
+ def detect
24
+ raise NotImplementedError, "#{self.class} must implement #detect"
25
+ end
26
+
27
+ private
28
+
29
+ # Builds a discovered fact with a provenance record in one call - the
30
+ # shape every detector uses.
31
+ def fact(key, category:, claim:, source:, method:, value: true, confidence: :medium, detail: nil)
32
+ DiscoveredFact.new(
33
+ key: key,
34
+ category: category,
35
+ claim: claim,
36
+ value: value,
37
+ confidence: confidence,
38
+ provenance: Provenance.new(source: source, method: method, detail: detail)
39
+ )
40
+ end
41
+
42
+ # Convenience: the first repository-relative path matching any of the
43
+ # given globs, or nil.
44
+ def first_path(*patterns)
45
+ patterns.flat_map { |pattern| repository.glob(pattern) }.first
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Detectors
5
+ # Detects the Ruby/Bundler ecosystem and the supported-runtime matrix
6
+ # (specification section 06: Ecosystem and Support matrix). Evidence is the
7
+ # presence of a Gemfile, a lockfile, and any runtime pin - .ruby-version,
8
+ # a gemspec `required_ruby_version`, or a Gemfile `ruby` directive.
9
+ class Bundler < Base
10
+ RUBY_VERSION_PIN = /required_ruby_version\s*=?\s*['"]?([^'"]+)['"]?/
11
+ GEMFILE_RUBY = /^\s*ruby\s+['"]([^'"]+)['"]/
12
+
13
+ # A single declared runtime version paired with the file it came from.
14
+ Pin = Struct.new(:version, :source)
15
+
16
+ def detect
17
+ return [] unless repository.exist?("Gemfile") || repository.any?("*.gemspec")
18
+
19
+ facts = [
20
+ fact(:bundler, category: :ecosystem,
21
+ claim: "Managed with Bundler.",
22
+ confidence: :high, source: gemfile_or_gemspec, method: :file_present)
23
+ ]
24
+
25
+ if repository.exist?("Gemfile.lock")
26
+ facts << fact(:locked_dependencies, category: :ecosystem,
27
+ claim: "Dependencies are locked (Gemfile.lock present).",
28
+ confidence: :high, source: "Gemfile.lock", method: :file_present)
29
+ end
30
+
31
+ pins = runtime_pins
32
+ unless pins.empty?
33
+ versions = pins.map(&:version)
34
+ facts << fact(:supported_ruby, category: :support_matrix,
35
+ claim: "Declared Ruby support: #{versions.join(", ")}.",
36
+ value: versions, confidence: :medium,
37
+ source: pins.first.source, method: :version_pin, detail: versions.join(", "))
38
+ end
39
+
40
+ facts
41
+ end
42
+
43
+ private
44
+
45
+ def gemfile_or_gemspec
46
+ repository.exist?("Gemfile") ? "Gemfile" : first_path("*.gemspec")
47
+ end
48
+
49
+ # @return [Array<Pin>] de-duplicated runtime pins, strongest source first
50
+ def runtime_pins
51
+ pins = []
52
+
53
+ if (rv = repository.read(".ruby-version"))
54
+ pins << Pin.new(rv.strip, ".ruby-version")
55
+ end
56
+ if (gemspec = first_path("*.gemspec")) && (body = repository.read(gemspec)) &&
57
+ (m = body.match(RUBY_VERSION_PIN))
58
+ pins << Pin.new(m[1].strip, gemspec)
59
+ end
60
+ if (gemfile = repository.read("Gemfile")) && (m = gemfile.match(GEMFILE_RUBY))
61
+ pins << Pin.new(m[1].strip, "Gemfile")
62
+ end
63
+
64
+ pins.uniq(&:version)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Detectors
5
+ # Discovers the commands a repository already uses to prove itself
6
+ # (specification section 06: Commands). The point is that users should
7
+ # "confirm facts rather than transcribe their own CI" - so this detector
8
+ # reads Rakefiles, lint configuration, gemspecs, and package.json scripts
9
+ # and reports the install/test/lint/build commands it finds. Each command
10
+ # fact later overrides the matching profile default (section 00: reuse
11
+ # detected checks; surface missing proof).
12
+ class Commands < Base
13
+ def detect
14
+ [
15
+ dependencies_command,
16
+ test_command,
17
+ lint_command,
18
+ build_command,
19
+ assets_command
20
+ ].compact
21
+ end
22
+
23
+ private
24
+
25
+ def dependencies_command
26
+ return unless repository.exist?("Gemfile")
27
+
28
+ command(:dependencies_command, "bundle check",
29
+ claim: "Dependency resolution is verified with `bundle check`.",
30
+ source: "Gemfile")
31
+ end
32
+
33
+ def test_command
34
+ if rspec?
35
+ command(:test_command, "bundle exec rspec",
36
+ claim: "Tests run with RSpec.", source: rspec_source, confidence: :high)
37
+ elsif repository.exist?("test") || repository.any?("test/**/*_test.rb")
38
+ command(:test_command, "bundle exec rake test",
39
+ claim: "Tests run with the default rake test task.", source: "test/")
40
+ elsif (script = package_script("test"))
41
+ command(:test_command, script,
42
+ claim: "Tests run via the package.json test script.", source: "package.json")
43
+ end
44
+ end
45
+
46
+ def lint_command
47
+ if repository.exist?(".standard.yml") || gemfile_mentions?("standard")
48
+ command(:lint_command, "bundle exec standardrb",
49
+ claim: "Lint runs with Standard.", source: standard_source, confidence: :high)
50
+ elsif repository.exist?(".rubocop.yml")
51
+ command(:lint_command, "bundle exec rubocop",
52
+ claim: "Lint runs with RuboCop.", source: ".rubocop.yml", confidence: :high)
53
+ end
54
+ end
55
+
56
+ def build_command
57
+ gemspec = first_path("*.gemspec")
58
+ return unless gemspec
59
+
60
+ command(:build_command, "gem build #{gemspec}",
61
+ claim: "Gem builds with `gem build`.", source: gemspec, confidence: :high)
62
+ end
63
+
64
+ def assets_command
65
+ script = package_script("build")
66
+ return unless script
67
+
68
+ command(:assets_command, script,
69
+ claim: "Assets build via the package.json build script.", source: "package.json")
70
+ end
71
+
72
+ # --- helpers -------------------------------------------------------------
73
+
74
+ def command(key, value, claim:, source:, confidence: :medium)
75
+ fact(key, category: :commands, claim: claim, value: value,
76
+ confidence: confidence, source: source, method: :command_inference, detail: value)
77
+ end
78
+
79
+ def rspec?
80
+ repository.exist?(".rspec") || repository.exist?("spec") || repository.any?("spec/**/*_spec.rb")
81
+ end
82
+
83
+ def rspec_source
84
+ repository.exist?(".rspec") ? ".rspec" : "spec/"
85
+ end
86
+
87
+ def standard_source
88
+ repository.exist?(".standard.yml") ? ".standard.yml" : "Gemfile"
89
+ end
90
+
91
+ def gemfile_mentions?(gem)
92
+ body = repository.read("Gemfile").to_s + repository.read("Gemfile.lock").to_s
93
+ body.include?(gem)
94
+ end
95
+
96
+ def package_script(name)
97
+ body = repository.read("package.json")
98
+ return unless body
99
+
100
+ require "json"
101
+ scripts = JSON.parse(body).fetch("scripts", {})
102
+ script = scripts[name]
103
+ return unless script
104
+
105
+ # Prefer the yarn/npm indirection so the discovered command is portable.
106
+ repository.exist?("yarn.lock") ? "yarn #{name}" : "npm run #{name}"
107
+ rescue JSON::ParserError
108
+ nil
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Detectors
5
+ # Detects that a repository is a fork and, when possible, its upstream
6
+ # (specification section 04 cohorts and section 10 relationships). A fork
7
+ # is classified differently from an active original - it may be upstream
8
+ # observation, divergence monitoring, or a candidate to retire - so knowing
9
+ # the fork relationship early shapes the entire onboarding default.
10
+ class Fork < Base
11
+ def detect
12
+ upstream = Git.capture(repository.path, "config", "--get", "remote.upstream.url")
13
+ return [] unless repository.fork? || upstream
14
+
15
+ upstream_name = RepositoryMetadata.full_name_from_url(upstream)
16
+ claim = upstream_name ? "A fork of #{upstream_name}." : "A fork with a configured upstream remote."
17
+
18
+ [fact(:fork_of, category: :relationships,
19
+ claim: claim, value: upstream_name, confidence: :high,
20
+ source: upstream ? "remote.upstream" : "git metadata", method: :git_metadata,
21
+ detail: upstream_name)]
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Detectors
5
+ # Reads .github/workflows/*.yml for the evidence CI already provides
6
+ # (specification section 06: Commands, Support matrix, Security controls,
7
+ # Operational proof). Reanimator's job is to surface what a maintainer
8
+ # has already proven, so a project with a Ruby matrix and a
9
+ # dependency-review action should never be asked to re-declare either.
10
+ class GitHubActions < Base
11
+ # Action fingerprints -> the security control they demonstrate.
12
+ SECURITY_ACTIONS = {
13
+ "actions/dependency-review-action" => "Dependency review",
14
+ "github/codeql-action" => "CodeQL SAST",
15
+ "gitleaks" => "Secret scanning (gitleaks)",
16
+ "trufflehog" => "Secret scanning (trufflehog)",
17
+ "brakeman" => "Brakeman SAST",
18
+ "bundler-audit" => "Bundler dependency audit",
19
+ "bundle audit" => "Bundler dependency audit"
20
+ }.freeze
21
+
22
+ RUBY_MATRIX = /ruby(?:-version)?\s*:\s*\[([^\]]+)\]/
23
+
24
+ def detect
25
+ workflows = repository.glob(".github/workflows/*.yml") + repository.glob(".github/workflows/*.yaml")
26
+ return [] if workflows.empty?
27
+
28
+ facts = [
29
+ fact(:ci_github_actions, category: :operational_proof,
30
+ claim: "Continuous integration runs on GitHub Actions.",
31
+ value: workflows, confidence: :high, source: workflows.first, method: :file_present)
32
+ ]
33
+
34
+ facts.concat(ruby_matrix_facts(workflows))
35
+ facts.concat(security_facts(workflows))
36
+ facts
37
+ end
38
+
39
+ private
40
+
41
+ def ruby_matrix_facts(workflows)
42
+ workflows.filter_map do |path|
43
+ body = repository.read(path)
44
+ next unless body && (m = body.match(RUBY_MATRIX))
45
+
46
+ versions = m[1].split(",").map { |v| v.strip.delete("\"'") }.reject(&:empty?)
47
+ next if versions.empty?
48
+
49
+ fact(:ci_ruby_matrix, category: :support_matrix,
50
+ claim: "CI exercises Ruby: #{versions.join(", ")}.",
51
+ value: versions, confidence: :high, source: path, method: :yaml_matrix, detail: versions.join(", "))
52
+ end
53
+ end
54
+
55
+ def security_facts(workflows)
56
+ text = workflows.map { |path| repository.read(path).to_s }.join("\n")
57
+ SECURITY_ACTIONS.filter_map do |marker, description|
58
+ next unless text.include?(marker)
59
+
60
+ key = description.downcase.gsub(/[^a-z]+/, "_").gsub(/\A_|_\z/, "")
61
+ fact(:"security_#{key}", category: :security_controls,
62
+ claim: "Security control present: #{description}.",
63
+ confidence: :high, source: ".github/workflows", method: :action_fingerprint, detail: marker)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end