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,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # What changed between two contract snapshots, and which of those changes a
5
+ # human has to look at (specification section 06).
6
+ #
7
+ # The distinction the specification insists on is not "big" versus "small" -
8
+ # it is *policy* versus *benign*. A renamed test command is benign: the
9
+ # obligation is unchanged, only its spelling moved. A removed security gate
10
+ # or a narrowed runtime matrix is a policy change: less is being proven than
11
+ # before, and nobody necessarily decided that on purpose. Drift detection
12
+ # earns its keep by never burying the second kind in a list of the first.
13
+ class DriftReport
14
+ # A single difference between two snapshots, carrying the verdict that
15
+ # judged it - a severity and the sentence justifying that severity - so no
16
+ # caller has to re-derive the judgement in order to display it. The verdict
17
+ # arrives whole and is kept whole: a severity can never be paired with the
18
+ # reasoning for a different one.
19
+ class Change
20
+ attr_reader :subject, :kind, :from, :to, :verdict
21
+
22
+ def initialize(subject:, kind:, verdict:, from: nil, to: nil)
23
+ @subject = subject
24
+ @kind = kind
25
+ @verdict = verdict
26
+ @from = from
27
+ @to = to
28
+ freeze
29
+ end
30
+
31
+ def severity = verdict.severity
32
+
33
+ def explanation = verdict.explanation
34
+
35
+ def policy? = severity == :policy
36
+
37
+ def benign? = severity == :benign
38
+
39
+ # A single line naming the difference.
40
+ def summary
41
+ case kind
42
+ when :added then "#{subject} added: #{to}"
43
+ when :removed then "#{subject} removed: #{from}"
44
+ else "#{subject}: #{from} → #{to}"
45
+ end
46
+ end
47
+
48
+ def to_h
49
+ {subject: subject, kind: kind, from: from, to: to, severity: severity, explanation: explanation}
50
+ end
51
+ end
52
+
53
+ attr_reader :repository, :previous, :current, :changes
54
+
55
+ def initialize(repository:, previous:, current:, changes:)
56
+ @repository = repository
57
+ @previous = previous
58
+ @current = current
59
+ @changes = changes
60
+ freeze
61
+ end
62
+
63
+ def policy_changes = changes.select(&:policy?)
64
+
65
+ def benign_changes = changes.select(&:benign?)
66
+
67
+ def drifted? = changes.any?
68
+
69
+ # Whether a human has to look. Benign drift is reported but does not
70
+ # demand attention; policy drift always does.
71
+ def requires_review? = policy_changes.any?
72
+
73
+ def since = previous.taken_at
74
+
75
+ def to_h
76
+ {
77
+ repository: repository,
78
+ since: since,
79
+ drifted: drifted?,
80
+ requires_review: requires_review?,
81
+ policy_changes: policy_changes.map(&:to_h),
82
+ benign_changes: benign_changes.map(&:to_h)
83
+ }
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Reanimator
6
+ # The fully resolved combination of policy layers plus accepted discoveries
7
+ # (specification section 02): exactly what Reanimator must prove for one
8
+ # repository. It is what the contract-review UI shows, what the baseline
9
+ # runs, and what every run is versioned against. It knows how to render
10
+ # itself back into a minimal, round-trippable .reanimator.yml.
11
+ class EffectiveContract
12
+ attr_reader :repository, :intent, :profile, :criteria, :discovery,
13
+ :security_posture, :autonomy, :protected_paths, :waivers, :notes,
14
+ :profile_source, :intent_confirmed
15
+
16
+ def initialize(repository:, intent:, profile:, criteria:, discovery:,
17
+ security_posture:, autonomy:, protected_paths:, profile_source:,
18
+ intent_confirmed:, waivers: [], notes: [])
19
+ @repository = repository
20
+ @intent = intent
21
+ @profile = profile
22
+ @criteria = criteria
23
+ @discovery = discovery
24
+ @security_posture = security_posture
25
+ @autonomy = autonomy
26
+ @protected_paths = protected_paths
27
+ @waivers = waivers
28
+ @profile_source = profile_source
29
+ @intent_confirmed = intent_confirmed
30
+ @notes = notes
31
+ end
32
+
33
+ # The active waiver accepting a criterion's risk, or nil. Expired waivers
34
+ # never apply (fail closed).
35
+ def waiver_for(criterion_id, now: Time.now)
36
+ waivers.find { |waiver| waiver.covers?(criterion_id) && waiver.active?(now: now) }
37
+ end
38
+
39
+ def intent_confirmed? = @intent_confirmed
40
+
41
+ # @return [Array<AcceptanceCriterion>] criteria whose failure blocks
42
+ def required_criteria = criteria.select(&:required?)
43
+
44
+ # @return [Array<AcceptanceCriterion>] non-blocking criteria
45
+ def optional_criteria = criteria.reject(&:required?)
46
+
47
+ # Criteria that touch verifier definitions or protected contract surfaces
48
+ # and therefore always receive elevated review (section 09).
49
+ def verifier_criteria = criteria.select(&:elevated?)
50
+
51
+ # A short, stable fingerprint of the materialized contract, so each run can
52
+ # be attributed to the exact policy it was measured against (section 17:
53
+ # "version the effective contract used for each run").
54
+ def policy_version
55
+ @policy_version ||= "#{profile.id}@#{Digest::SHA256.hexdigest(canonical.to_s)[0, 12]}"
56
+ end
57
+
58
+ # The minimal, owner-facing declaration. This is the target for ordinary
59
+ # configuration (section 05): one lifecycle decision and one profile.
60
+ def to_minimal_hash
61
+ {version: ContractFile::CURRENT_VERSION, intent: intent.to_sym, profile: profile.id}
62
+ end
63
+
64
+ # The fuller contract hash used for advanced export. Must round-trip
65
+ # through {ContractFile} without semantic loss (section 07).
66
+ def to_contract_hash
67
+ to_minimal_hash.merge(
68
+ acceptance: {
69
+ checks: criteria.map { |c| c.to_h.slice(:id, :evaluator, :run, :target, :required).compact }
70
+ },
71
+ security: {posture: security_posture.to_sym},
72
+ autonomy: autonomy,
73
+ protected_paths: protected_paths
74
+ )
75
+ end
76
+
77
+ def to_h
78
+ {
79
+ repository: repository.full_name,
80
+ intent: intent.to_sym,
81
+ intent_confirmed: intent_confirmed?,
82
+ profile: profile.id,
83
+ profile_source: profile_source,
84
+ security_posture: security_posture.to_sym,
85
+ autonomy: autonomy,
86
+ protected_paths: protected_paths,
87
+ policy_version: policy_version,
88
+ criteria: criteria.map(&:to_h),
89
+ notes: notes
90
+ }
91
+ end
92
+
93
+ private
94
+
95
+ # The subset that defines the contract's identity for fingerprinting.
96
+ def canonical
97
+ {
98
+ intent: intent.to_sym,
99
+ profile: profile.id,
100
+ posture: security_posture.to_sym,
101
+ autonomy: autonomy,
102
+ protected: protected_paths.sort,
103
+ criteria: criteria.map { |c| [c.id, c.evaluator, c.run, c.required] }.sort_by(&:first),
104
+ waivers: waivers.map { |w| [w.target, w.expires&.iso8601] }.sort_by(&:first)
105
+ }
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Materializes an {EffectiveContract} from the precedence stack in section
5
+ # 07. For this read-only slice the layers that participate are the product
6
+ # safety floor (non-bypassable), the repository contract (owner declarations
7
+ # in .reanimator.yml), and runtime discovery (detected commands and
8
+ # metadata, which may never authorize weaker behavior by itself). Higher
9
+ # organization/portfolio/family layers plug in at the same seam later.
10
+ #
11
+ # Two rules are enforced here, not merely documented:
12
+ # * an owner declaration wins over inference, but
13
+ # * neither an owner declaration nor discovery may loosen the safety floor.
14
+ class EffectivePolicyResolver
15
+ # v1 autonomy defaults (section 20): observe and diagnose on; repair and
16
+ # submit opt-in; merge off.
17
+ DEFAULT_AUTONOMY = {
18
+ observe: :automatic,
19
+ diagnose: :automatic,
20
+ repair: :disabled,
21
+ submit: :disabled,
22
+ merge: :disabled
23
+ }.freeze
24
+
25
+ attr_reader :repository, :discovery
26
+
27
+ def initialize(repository, discovery: nil)
28
+ @repository = repository
29
+ @discovery = discovery || Discovery.new(repository).run
30
+ end
31
+
32
+ # @return [EffectiveContract]
33
+ def resolve
34
+ intent, intent_confirmed = resolve_intent
35
+ profile, profile_source = resolve_profile(intent)
36
+ autonomy, autonomy_notes = SafetyFloor.clamp_autonomy(resolve_autonomy)
37
+ posture = resolve_security_posture
38
+
39
+ EffectiveContract.new(
40
+ repository: repository,
41
+ intent: intent,
42
+ intent_confirmed: intent_confirmed,
43
+ profile: profile,
44
+ profile_source: profile_source,
45
+ criteria: CriteriaStack.new(
46
+ profile: profile, posture: posture, discovery: discovery, declared: declared
47
+ ).resolve,
48
+ discovery: discovery,
49
+ security_posture: posture,
50
+ autonomy: autonomy,
51
+ protected_paths: SafetyFloor.protected_paths(declared_protected_paths),
52
+ waivers: build_waivers,
53
+ notes: autonomy_notes
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ def declared = repository.declared
60
+
61
+ # Assigned intent wins, then a declared one, then a provisional suggestion
62
+ # from the classifier. Only the first two count as "confirmed".
63
+ def resolve_intent
64
+ if repository.intent
65
+ [LifecycleIntent.fetch(repository.intent), true]
66
+ elsif declared&.intent
67
+ [LifecycleIntent.fetch(declared.intent), true]
68
+ else
69
+ suggestion = Classifier.new(repository, discovery: discovery).classify
70
+ [LifecycleIntent.fetch(suggestion.suggested_intent), false]
71
+ end
72
+ end
73
+
74
+ # A declared profile always wins over inference (section 06). When the
75
+ # inferred profile is rails-app but the intent is to preserve, the
76
+ # historical variant is the better fit.
77
+ def resolve_profile(intent)
78
+ if declared&.profile
79
+ [Profiles.fetch(declared.profile), :declared]
80
+ else
81
+ id = discovery.suggested_profile
82
+ id = :"rails-legacy" if id == :"rails-app" && intent.to_sym == :preserve
83
+ [Profiles.fetch(id), :inferred]
84
+ end
85
+ end
86
+
87
+ def declared_protected_paths
88
+ declared&.acceptance&.dig(:protected_paths) || []
89
+ end
90
+
91
+ # Owner-declared waivers become accepted-risk exceptions on the contract.
92
+ def build_waivers
93
+ (declared&.waivers || []).map do |waiver|
94
+ Waiver.new(
95
+ target: waiver[:target] || waiver[:id],
96
+ reason: waiver[:reason].to_s,
97
+ owner: waiver[:owner] || "repository",
98
+ scope: waiver[:scope] || {},
99
+ expires: waiver[:expires]
100
+ )
101
+ end
102
+ end
103
+
104
+ def resolve_autonomy
105
+ declared_autonomy = declared&.autonomy || {}
106
+ DEFAULT_AUTONOMY.merge(declared_autonomy.transform_values(&:to_sym))
107
+ end
108
+
109
+ def resolve_security_posture
110
+ declared_posture = declared&.security&.dig(:posture)
111
+ return SecurityPosture[declared_posture] if declared_posture
112
+
113
+ SecurityPosture[Reanimator.configuration.security_posture]
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Base class for every error the library raises, so consumers can rescue the
5
+ # whole family with a single `rescue Reanimator::Error`.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when a path is not a usable repository root.
9
+ class RepositoryError < Error; end
10
+
11
+ # Raised when a .reanimator.yml (or an inline contract) fails validation.
12
+ class ContractError < Error; end
13
+
14
+ # Raised when a requested profile, intent, or evaluator is not registered.
15
+ class UnknownError < Error; end
16
+
17
+ # Raised when a policy layer attempts to loosen a constraint it may not
18
+ # loosen - the precedence rules in section 07 of the specification are not
19
+ # advisory, they are enforced.
20
+ class PolicyViolation < Error; end
21
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Evaluators
5
+ # Shared plumbing for evaluators. Subclasses implement {#call} and return a
6
+ # {CriterionResult}. The evaluator sees the repository and discovery only
7
+ # through the read-only effective contract - it never receives, and cannot
8
+ # reach, a mutable agent workspace.
9
+ class Base
10
+ attr_reader :criterion, :contract, :execute, :timeout, :execution_root
11
+
12
+ def initialize(criterion, contract:, execute:, timeout:, execution_root: nil)
13
+ @criterion = criterion
14
+ @contract = contract
15
+ @execute = execute
16
+ @timeout = timeout
17
+ @execution_root = execution_root || contract.repository.path
18
+ end
19
+
20
+ # @return [CriterionResult]
21
+ def call
22
+ raise NotImplementedError, "#{self.class} must implement #call"
23
+ end
24
+
25
+ private
26
+
27
+ def repository = contract.repository
28
+
29
+ def discovery = contract.discovery
30
+
31
+ def result(status, detail: nil, evidence: nil, duration: nil)
32
+ CriterionResult.new(
33
+ criterion: criterion, status: status, detail: detail, evidence: evidence, duration: duration
34
+ )
35
+ end
36
+
37
+ # Keep only the tail of noisy command output as retained evidence.
38
+ def tail(text, limit: 1_500)
39
+ stripped = text.to_s.strip
40
+ return stripped if stripped.length <= limit
41
+
42
+ "...#{stripped[-limit..]}"
43
+ end
44
+ end
45
+
46
+ # Fallback for a criterion whose evaluator is not available in the
47
+ # read-only baseline (for example a hosted GitHub check). It is honest
48
+ # about the gap rather than guessing.
49
+ class Unsupported < Base
50
+ def call
51
+ result(:skipped, detail: "Evaluator #{criterion.evaluator.inspect} is not available in a read-only baseline.")
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Evaluators
5
+ # Runs a discovered or declared shell command and grades it by exit status.
6
+ # In the default (non-executing) mode it reports what it *would* run, so a
7
+ # user can see the plan before granting execution. When execution is
8
+ # enabled the command is bounded by a hard timeout.
9
+ class Command < Base
10
+ def call
11
+ return result(:missing_evidence, detail: "No command discovered for this check.") unless criterion.run
12
+
13
+ unless execute
14
+ return result(:planned, detail: "Would run: #{criterion.run}")
15
+ end
16
+
17
+ outcome = BoundedCommand.run(criterion.run, chdir: execution_root, timeout: timeout)
18
+ grade(outcome)
19
+ end
20
+
21
+ private
22
+
23
+ def grade(outcome)
24
+ if outcome.timed_out?
25
+ result(:failed, detail: "Timed out after #{timeout}s: #{criterion.run}", duration: outcome.duration)
26
+ elsif outcome.success?
27
+ result(:verified, detail: criterion.run, evidence: tail(outcome.output), duration: outcome.duration)
28
+ else
29
+ result(:failed, detail: "Nonzero exit: #{criterion.run}", evidence: tail(outcome.output), duration: outcome.duration)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Evaluators
5
+ # A native evaluator that checks for the presence of documentation the
6
+ # criterion names (README, LICENSE, or any target prefix). Cheap, read-only,
7
+ # and always runnable - it backs the "reproduction-documented" and
8
+ # "documented/licensed" criteria without executing anything.
9
+ class DocumentationCheck < Base
10
+ KNOWN = {"README" => "README*", "LICENSE" => "LICENSE*"}.freeze
11
+
12
+ def call
13
+ target = criterion.target || "README"
14
+ pattern = KNOWN.fetch(target, "#{target}*")
15
+ matches = repository.glob(pattern)
16
+
17
+ if matches.any?
18
+ result(:verified, detail: "Found #{matches.first}.")
19
+ else
20
+ result(:missing_evidence, detail: "No #{target} found.")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Evaluators
5
+ # A boot smoke test against a health endpoint. Actually booting a service
6
+ # requires a running environment the read-only baseline deliberately does
7
+ # not provision, so this evaluator records the gap honestly - the criterion
8
+ # is a real, valuable proof-of-life check, it is simply not yet established.
9
+ # This is exactly the "production boot proof not established" gap Appendix A
10
+ # reports as Unproven rather than green.
11
+ class HttpSmoke < Base
12
+ def call
13
+ result(:missing_evidence,
14
+ detail: "Boot smoke against #{criterion.target || "the health endpoint"} needs a running " \
15
+ "service; not established in a read-only baseline.")
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Evaluators
5
+ # A native evaluator for preserve/legacy repositories: attests that source
6
+ # remains readable with intact history. It only inspects git metadata, so
7
+ # it runs even in the non-executing baseline - a preserved artifact can be
8
+ # proven readable without running a single line of its code.
9
+ class RepositoryIntegrity < Base
10
+ def call
11
+ metadata = repository.metadata
12
+ if metadata.has_git?
13
+ result(:verified,
14
+ detail: "Git history is intact (#{metadata.commit_count || "unknown"} commits, " \
15
+ "last activity #{metadata.last_activity&.strftime("%Y-%m-%d") || "unknown"}).")
16
+ else
17
+ result(:missing_evidence, detail: "No git history found; source integrity cannot be attested.")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Evaluators
5
+ # A native evaluator that grades one security control from discovered
6
+ # evidence (specification section 12). It never asks a repository to adopt
7
+ # a particular tool - any of the control's accepted fingerprints counts -
8
+ # and a control with no evidence is reported as missing rather than
9
+ # quietly passed, which is the whole point of making the posture binding.
10
+ class SecurityEvidence < Base
11
+ def call
12
+ control = SecurityControl.fetch(criterion.target)
13
+ evidence = control.evidence_in(discovery)
14
+ return result(:missing_evidence, detail: control.remedy) unless evidence
15
+
16
+ result(:verified, detail: evidence.claim, evidence: evidence.provenance.to_s)
17
+ rescue UnknownError => e
18
+ # A contract naming a control we do not know is a misconfiguration, not
19
+ # a pass: say so, and let the unproven rules keep the repository honest.
20
+ result(:error, detail: e.message)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Evaluators
5
+ # A native evaluator that compares declared runtime support against what CI
6
+ # actually exercises. This is the classic escalation from section 16: "the
7
+ # project declares Ruby 3.2 support, but CI no longer tests it." Declared
8
+ # support with no CI coverage is missing evidence, not a pass.
9
+ class SupportMatrix < Base
10
+ def call
11
+ ci = discovery[:ci_ruby_matrix]
12
+ declared = discovery[:supported_ruby]
13
+
14
+ if ci
15
+ result(:verified, detail: "CI exercises Ruby #{Array(ci.value).join(", ")}.")
16
+ elsif declared
17
+ result(:missing_evidence,
18
+ detail: "Ruby #{Array(declared.value).join(", ")} is declared but not exercised in CI.")
19
+ else
20
+ result(:skipped, detail: "No declared runtime support matrix to verify.")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Evaluators turn an acceptance criterion into a {CriterionResult}. They are
5
+ # the isolated grading half of section 09: the thing that decides pass or
6
+ # fail is deliberately separate from anything that could edit the repository.
7
+ #
8
+ # Two families live here. *Native* evaluators (repository integrity,
9
+ # documentation, support matrix, security evidence) read only inspection
10
+ # data and always run,
11
+ # even in the non-executing default - that is how a read-only baseline can
12
+ # still prove something. *Command* and *smoke* evaluators represent work that
13
+ # must actually execute, so they only run when a baseline is explicitly asked
14
+ # to execute.
15
+ module Evaluators
16
+ module_function
17
+
18
+ # @param criterion [AcceptanceCriterion]
19
+ # @param contract [EffectiveContract]
20
+ # @param execute [Boolean]
21
+ # @param timeout [Integer]
22
+ # @param execution_root [String] directory commands run in - a disposable
23
+ # copy when executing, so the original working tree is never touched
24
+ # @return [Evaluators::Base]
25
+ def for(criterion, contract:, execute:, timeout:, execution_root: contract.repository.path)
26
+ klass = registry.fetch(criterion.evaluator, Unsupported)
27
+ klass.new(criterion, contract: contract, execute: execute, timeout: timeout, execution_root: execution_root)
28
+ end
29
+
30
+ def registry
31
+ {
32
+ command: Command,
33
+ http_smoke: HttpSmoke,
34
+ repository_integrity: RepositoryIntegrity,
35
+ documentation_check: DocumentationCheck,
36
+ support_matrix: SupportMatrix,
37
+ security_evidence: SecurityEvidence
38
+ }
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ class Family
5
+ # A cross-repository requirement (specification section 10 family policy):
6
+ # when the `on` member is a candidate for change, a check in another member
7
+ # must pass. Family policy may *add* evidence like this, but it can never
8
+ # weaken a member repository's own security constraints.
9
+ #
10
+ # A gate with no explicit check falls back to the downstream member's own
11
+ # proof command (a smoke script, else its discovered test command), so an
12
+ # inferred family is useful without anyone writing YAML.
13
+ class Gate
14
+ # @return [String] the member whose change triggers this gate
15
+ attr_reader :on
16
+ # @return [String] the member that must prove itself
17
+ attr_reader :requires_repository
18
+ # @return [String, nil] the command to run, or nil to use the member's own
19
+ attr_reader :check
20
+
21
+ def initialize(on:, requires_repository:, check: nil)
22
+ @on = on
23
+ @requires_repository = requires_repository
24
+ @check = check
25
+ freeze
26
+ end
27
+
28
+ # Whether the check command must be resolved from the downstream member.
29
+ def inferred_check? = check.nil?
30
+
31
+ def label
32
+ "#{on} requires #{check || "its own proof command"} in #{requires_repository}"
33
+ end
34
+
35
+ def to_h
36
+ {on: on, require: {repository: requires_repository, check: check}.compact}
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ class Family
5
+ # The outcome of one cross-repository gate. Section 10 is explicit that
6
+ # "family failures identify the exact edge that broke, not merely the
7
+ # repository that ran CI", so a result carries the relationship it was
8
+ # proving, not just a repository name.
9
+ class GateResult
10
+ STATUSES = %i[verified failed missing_evidence planned skipped].freeze
11
+
12
+ attr_reader :gate, :relationship, :status, :detail, :duration
13
+
14
+ def initialize(gate:, status:, relationship: nil, detail: nil, duration: nil)
15
+ @gate = gate
16
+ @status = status.to_sym
17
+ unless STATUSES.include?(@status)
18
+ raise UnknownError, "Unknown gate result status #{status.inspect}."
19
+ end
20
+
21
+ @relationship = relationship
22
+ @detail = detail
23
+ @duration = duration
24
+ end
25
+
26
+ def verified? = status == :verified
27
+
28
+ def failed? = status == :failed
29
+
30
+ # Not proven yet: nothing ran, or no downstream proof exists.
31
+ def unresolved? = %i[missing_evidence planned skipped].include?(status)
32
+
33
+ # The exact edge this result speaks for.
34
+ def edge
35
+ relationship&.label || "#{gate.on} -> #{gate.requires_repository}"
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ edge: edge, on: gate.on, requires: gate.requires_repository,
41
+ status: status, detail: detail, duration: duration
42
+ }.compact
43
+ end
44
+ end
45
+ end
46
+ end