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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # The set of repositories visible to one installation, grouped for triage and
5
+ # stewardship (specification section 02). Building a portfolio is the very
6
+ # first onboarding step (section 03 stage 1): classify before acting. It is
7
+ # read-only and never preselects everything - the recommended set is the
8
+ # small, high-confidence group of active originals (section 04 default).
9
+ class Portfolio
10
+ # One repository plus how intake classified it.
11
+ Entry = Struct.new(:repository, :classification) do
12
+ def name = repository.name
13
+
14
+ def cohort = classification.cohort
15
+
16
+ def suggested_intent = classification.suggested_intent
17
+
18
+ def recommended? = classification.recommended_for_onboarding?
19
+ end
20
+
21
+ attr_reader :entries
22
+
23
+ def initialize(entries)
24
+ @entries = entries
25
+ end
26
+
27
+ # Classifies each path that is a repository, skipping the rest.
28
+ # @param paths [Array<String>]
29
+ # @return [Portfolio]
30
+ def self.scan(paths)
31
+ entries = paths.filter_map do |path|
32
+ repository = begin
33
+ Repository.at(path)
34
+ rescue RepositoryError
35
+ Reanimator.logger.warn("Skipping #{path.inspect}: not a repository.")
36
+ nil
37
+ end
38
+ next unless repository
39
+
40
+ Entry.new(repository: repository, classification: Classifier.new(repository).classify)
41
+ end
42
+
43
+ new(entries)
44
+ end
45
+
46
+ def size = entries.size
47
+
48
+ # @return [Array<Entry>] repositories recommended for active onboarding
49
+ def recommended = entries.select(&:recommended?)
50
+
51
+ # Entries grouped by cohort, in the canonical cohort order.
52
+ # @return [Hash{Symbol=>Array<Entry>}]
53
+ def by_cohort
54
+ Cohort::ALL.keys.each_with_object({}) do |key, grouped|
55
+ found = entries.select { |e| e.cohort.key == key }
56
+ grouped[key] = found unless found.empty?
57
+ end
58
+ end
59
+
60
+ def to_h
61
+ {
62
+ size: size,
63
+ recommended: recommended.map(&:name),
64
+ cohorts: by_cohort.transform_values { |es| es.map(&:name) }
65
+ }
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Reanimator
6
+ # Configuration drift across a whole portfolio (section 06 applied to the
7
+ # portfolio of section 04). One repository's drift is a question; a
8
+ # portfolio's is a triage list, so the entries sort themselves into what
9
+ # needs a human, what changed harmlessly, what is unchanged, and what was
10
+ # never recorded in the first place.
11
+ #
12
+ # A portfolio run is never stopped by one bad member: a repository that
13
+ # cannot be read, or whose snapshot cannot be parsed, becomes a failed entry
14
+ # and the rest still report.
15
+ class PortfolioDrift
16
+ # One repository's outcome. `report` is nil when nothing has been recorded
17
+ # for that repository yet, which is a finding in its own right rather than
18
+ # a quiet pass.
19
+ Entry = Struct.new(:name, :report, :snapshot, :recorded_to, :error) do
20
+ # Compare one repository against whatever the store has on record. The
21
+ # entry builds itself because it is the thing that knows its own shape -
22
+ # both a portfolio scan and a single-repository run want exactly this.
23
+ #
24
+ # @param path [String] a repository root on disk
25
+ # @param store [SnapshotStore]
26
+ # @param record [Boolean] write the current snapshot after comparing
27
+ # @param intent [Symbol, nil]
28
+ # @return [Entry]
29
+ def self.for(path, store:, record: false, intent: nil)
30
+ repository = Repository.at(path)
31
+ repository.intent = intent if intent
32
+ current = ContractSnapshot.of(EffectivePolicyResolver.new(repository).resolve)
33
+ # Loaded before anything is written, so recording and comparing in one
34
+ # pass still compares against what was previously on record.
35
+ previous = store.load(repository)
36
+
37
+ new(
38
+ name: repository.full_name,
39
+ report: previous && DriftDetector.between(previous, current),
40
+ snapshot: current,
41
+ recorded_to: (store.save(current, repository) if record)
42
+ )
43
+ rescue Reanimator::Error => e
44
+ new(name: File.basename(File.expand_path(path)), error: e.message)
45
+ end
46
+
47
+ def failed? = !error.nil?
48
+
49
+ def recorded? = !report.nil?
50
+
51
+ def requires_review? = recorded? && report.requires_review?
52
+
53
+ def drifted? = recorded? && report.drifted?
54
+
55
+ # Changed, but only in ways that prove the same or more than before.
56
+ def benign? = drifted? && !requires_review?
57
+
58
+ def unchanged? = recorded? && !drifted?
59
+
60
+ # Nothing to compare against: not a pass, just an absence.
61
+ def unrecorded? = !recorded? && !failed?
62
+ end
63
+
64
+ # @param paths [Array<String>] repository roots on disk
65
+ # @param store [SnapshotStore]
66
+ # @param record [Boolean] write each repository's current snapshot
67
+ # @param intent [Symbol, nil] override the inferred lifecycle intent
68
+ # @return [PortfolioDrift]
69
+ def self.scan(paths, store: SnapshotStore.new, record: false, intent: nil, name: "portfolio")
70
+ entries = Array(paths).map { |path| Entry.for(path, store: store, record: record, intent: intent) }
71
+ new(entries: entries, name: name, store: store)
72
+ end
73
+
74
+ attr_reader :entries, :name, :store, :generated_at
75
+
76
+ def initialize(entries:, name: "portfolio", store: SnapshotStore.new, generated_at: nil)
77
+ @entries = entries
78
+ @name = name
79
+ @store = store
80
+ @generated_at = generated_at || Time.now
81
+ end
82
+
83
+ def policy_changed = entries.select(&:requires_review?)
84
+
85
+ def benign = entries.select(&:benign?)
86
+
87
+ def unchanged = entries.select(&:unchanged?)
88
+
89
+ def unrecorded = entries.select(&:unrecorded?)
90
+
91
+ def failed = entries.select(&:failed?)
92
+
93
+ # Whether anything in the portfolio asks for a human - what a CI gate keys
94
+ # off. Benign change does not count, and nor does a repository with nothing
95
+ # recorded yet: that is a known first-run state the report names plainly.
96
+ # A repository whose snapshot could not be *read* does count. "We could not
97
+ # tell" is not "nothing changed", and corrupting a snapshot file must never
98
+ # be a way to pass.
99
+ def requires_review? = policy_changed.any? || failed.any?
100
+
101
+ def to_h
102
+ {
103
+ portfolio: name,
104
+ generated_at: generated_at.iso8601,
105
+ requires_review: requires_review?,
106
+ tally: {
107
+ policy_changed: policy_changed.size, benign: benign.size,
108
+ unchanged: unchanged.size, unrecorded: unrecorded.size, failed: failed.size
109
+ },
110
+ repositories: entries.map { |entry| entry_hash(entry) }
111
+ }
112
+ end
113
+
114
+ private
115
+
116
+ def entry_hash(entry)
117
+ {repository: entry.name, error: entry.error, drift: entry.report&.to_h}.compact
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Chooses the vitality profile a repository most likely wants, from its
5
+ # discovered facts (specification section 06). The choice is a suggestion,
6
+ # not a decision - it is preselected and shown with its reasoning, and an
7
+ # owner-declared profile always wins over it (handled in the resolver).
8
+ module ProfileInference
9
+ module_function
10
+
11
+ # @param discovery [DiscoveryResult]
12
+ # @return [DiscoveryResult::Suggestion]
13
+ def call(discovery)
14
+ if discovery.fact?(:rails_app)
15
+ suggest(:"rails-app", :high, "config/application.rb boots Rails.")
16
+ elsif discovery.fact?(:fork_of)
17
+ suggest(:"maintained-fork", :high, "The repository is a fork with an upstream.")
18
+ elsif discovery.fact?(:ruby_gem)
19
+ if discovery.fact?(:executables)
20
+ suggest(:"ruby-cli", :high, "A gemspec plus shipped executables indicate a CLI.")
21
+ else
22
+ suggest(:"ruby-gem", :high, "A gemspec indicates a published gem.")
23
+ end
24
+ elsif discovery.fact?(:bundler)
25
+ suggest(:generic, :medium, "A Ruby project without a gemspec or Rails app; using the generic profile.")
26
+ else
27
+ suggest(:generic, :low, "No strong ecosystem signal; using the generic profile.")
28
+ end
29
+ end
30
+
31
+ def suggest(profile, confidence, reason)
32
+ DiscoveryResult::Suggestion.new(
33
+ profile: profile, confidence: Confidence[confidence], reason: reason
34
+ )
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # The registry of built-in vitality profiles. These are the "reusable
5
+ # bundles of obligations for a common project shape" from section 02, seeded
6
+ # with the ecosystems the specification prioritizes for v1 (Ruby and Rails,
7
+ # section 20). Each profile's criteria are *defaults*: discovery fills in the
8
+ # actual commands a repository uses, and anything a profile expects but
9
+ # discovery cannot find is surfaced as missing proof rather than assumed.
10
+ module Profiles
11
+ module_function
12
+
13
+ # @param id [Symbol, String]
14
+ # @return [VitalityProfile]
15
+ # @raise [UnknownError] when no such profile is registered
16
+ def fetch(id)
17
+ all.fetch(id.to_sym) do
18
+ raise UnknownError, "Unknown vitality profile #{id.inspect}. Known: #{all.keys.join(", ")}."
19
+ end
20
+ end
21
+
22
+ def registered?(id) = all.key?(id.to_sym)
23
+
24
+ def ids = all.keys
25
+
26
+ # @return [Hash{Symbol=>VitalityProfile}]
27
+ def all
28
+ @all ||= build.freeze
29
+ end
30
+
31
+ # Convenience for building a criterion with profile-friendly defaults.
32
+ def criterion(id, claim, evaluator:, run: nil, target: nil, required: true, risk_class: :normal)
33
+ AcceptanceCriterion.new(
34
+ id: id, claim: claim, evaluator: evaluator, run: run, target: target,
35
+ required: required, owner: "profile", risk_class: risk_class
36
+ )
37
+ end
38
+
39
+ def build
40
+ {
41
+ generic: VitalityProfile.new(
42
+ id: :generic,
43
+ summary: "Any repository: documented, licensed, and readable.",
44
+ criteria: [
45
+ criterion(:documented, "Project has a README.", evaluator: :documentation_check, target: "README", required: false),
46
+ criterion(:licensed, "Project declares a license.", evaluator: :documentation_check, target: "LICENSE", required: false)
47
+ ]
48
+ ),
49
+ "ruby-gem": VitalityProfile.new(
50
+ id: :"ruby-gem",
51
+ summary: "A published Ruby gem: resolves, tests, lints, and builds.",
52
+ criteria: [
53
+ criterion(:dependencies, "Dependencies resolve.", evaluator: :command, run: "bundle check"),
54
+ criterion(:tests, "Test suite passes.", evaluator: :command, run: "bundle exec rake spec"),
55
+ criterion(:lint, "Lint passes.", evaluator: :command, run: "bundle exec standardrb", required: false),
56
+ criterion(:build, "Gem builds.", evaluator: :command, run: "gem build *.gemspec"),
57
+ criterion(:support_matrix, "Declared Ruby versions are exercised.", evaluator: :support_matrix, required: false)
58
+ ]
59
+ ),
60
+ "ruby-cli": VitalityProfile.new(
61
+ id: :"ruby-cli",
62
+ summary: "A Ruby command-line tool: everything a gem needs plus a runnable executable.",
63
+ inherits: [:"ruby-gem"],
64
+ criteria: [
65
+ criterion(:executable, "Executable boots and answers --help.", evaluator: :command, required: false)
66
+ ]
67
+ ),
68
+ "web-application": VitalityProfile.new(
69
+ id: :"web-application",
70
+ summary: "A deployable web application that must boot and answer a health endpoint.",
71
+ criteria: [
72
+ criterion(:boot, "Application boots and answers its health endpoint.", evaluator: :http_smoke, target: "/up")
73
+ ]
74
+ ),
75
+ "rails-app": VitalityProfile.new(
76
+ id: :"rails-app",
77
+ summary: "A maintained Rails application: tests, assets, and boot.",
78
+ inherits: [:"web-application"],
79
+ criteria: [
80
+ criterion(:tests, "Test suite passes.", evaluator: :command, run: "bin/rails test"),
81
+ criterion(:assets, "Frontend assets build.", evaluator: :command, run: "yarn build", required: false)
82
+ ]
83
+ ),
84
+ "rails-legacy": VitalityProfile.new(
85
+ id: :"rails-legacy",
86
+ summary: "A historical Rails application preserved for readability and provenance.",
87
+ criteria: [
88
+ criterion(:"source-readable", "Source remains readable with intact history.", evaluator: :repository_integrity),
89
+ criterion(:"reproduction-documented", "Reproduction instructions are documented.", evaluator: :documentation_check, target: "README")
90
+ ]
91
+ ),
92
+ "maintained-fork": VitalityProfile.new(
93
+ id: :"maintained-fork",
94
+ summary: "A maintained fork tracked for upstream divergence and unique commits.",
95
+ criteria: [
96
+ criterion(:divergence, "Unique commits and upstream divergence are known.", evaluator: :repository_integrity, required: false)
97
+ ]
98
+ ),
99
+ "constrained-hardware": VitalityProfile.new(
100
+ id: :"constrained-hardware",
101
+ summary: "A hardware- or credential-dependent project verified within declared boundaries.",
102
+ criteria: [
103
+ criterion(:"unit-tests", "Unit tests pass on hosted runners.", evaluator: :command, run: "bundle exec rake test"),
104
+ criterion(:"package-build", "Package builds.", evaluator: :command, run: "gem build *.gemspec")
105
+ ]
106
+ )
107
+ }
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Computes a repository's proof-of-life state from its baseline results and
5
+ # its lifecycle intent (specification section 15). The rules encode two
6
+ # non-negotiables: a failing required check is never green, and a claim
7
+ # without executable evidence is Unproven rather than Alive. Intent shapes
8
+ # the reading - a preserved artifact that proves its integrity is Sleeping,
9
+ # not Alive, because it is intentionally not actively maintained.
10
+ module ProofOfLife
11
+ module_function
12
+
13
+ # @param contract [EffectiveContract]
14
+ # @param results [Array<CriterionResult>]
15
+ # @return [RepositoryState]
16
+ def assess(contract, results)
17
+ required = results.select(&:required?)
18
+ failed = required.select(&:failed?)
19
+ unproven = required.select(&:unproven?)
20
+ intent = contract.intent.to_sym
21
+
22
+ RepositoryState.fetch(state_key(intent, failed, unproven))
23
+ end
24
+
25
+ def state_key(intent, failed, unproven)
26
+ return :retired if intent == :retire
27
+
28
+ if failed.any?
29
+ :at_risk
30
+ elsif unproven.any?
31
+ (intent == :upstream) ? :sleeping : :unproven
32
+ else
33
+ proven_state(intent)
34
+ end
35
+ end
36
+
37
+ # State for a repository whose required criteria are all verified.
38
+ def proven_state(intent)
39
+ case intent
40
+ when :maintain, :incubate then :alive
41
+ else :sleeping # monitor, preserve, upstream, ignore
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Reanimator
6
+ # A repair that has been produced and independently verified but **not
7
+ # submitted** (specification section 11 autonomy ladder: repair creates a
8
+ # bounded patch "with no repository write required until submission"). It
9
+ # carries everything a draft pull request would need - branch name, patch,
10
+ # title, and body - so submission becomes a separate, explicitly authorized
11
+ # step rather than a side effect of repair.
12
+ class ProposedChange
13
+ attr_reader :obligation, :patch, :plan, :pre_state, :post_state
14
+
15
+ def initialize(obligation:, patch:, plan:, pre_state:, post_state:)
16
+ @obligation = obligation
17
+ @patch = patch
18
+ @plan = plan
19
+ @pre_state = pre_state
20
+ @post_state = post_state
21
+ end
22
+
23
+ # A deterministic branch name derived from the obligation and the patch, so
24
+ # the same repair maps to the same branch (idempotence).
25
+ def branch
26
+ slug = obligation.target.id.to_s.gsub(/[^a-z0-9]+/i, "-")
27
+ fingerprint = Digest::SHA256.hexdigest(patch.paths.sort.join(","))[0, 8]
28
+ "reanimator/repair-#{slug}-#{fingerprint}"
29
+ end
30
+
31
+ def title
32
+ "Fix: #{obligation.target.claim}"
33
+ end
34
+
35
+ def body
36
+ <<~BODY.strip
37
+ Reanimator proposes this bounded repair for `#{obligation.target.id}`.
38
+
39
+ Objective: #{obligation.target.claim}
40
+ Result: #{pre_state} → #{post_state} (independently verified)
41
+ Files: #{patch.paths.join(", ")}
42
+
43
+ #{plan}
44
+
45
+ This change was produced by an isolated agent and graded by Reanimator's
46
+ own evaluators; the agent did not grade its own work. Merge remains a
47
+ human decision.
48
+ BODY
49
+ end
50
+
51
+ def to_h
52
+ {branch: branch, title: title, files: patch.paths, pre_state: pre_state, post_state: post_state}
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # The audit trail behind a single discovered fact (specification section
5
+ # 06): its source, how it was extracted, and when. Provenance is what lets
6
+ # the interface always answer "Why does Reanimator believe this?" - no
7
+ # fact exists in the system without one.
8
+ class Provenance
9
+ # @return [String] the concrete evidence, e.g. "reanimator.gemspec" or
10
+ # ".github/workflows/main.yml"
11
+ attr_reader :source
12
+ # @return [Symbol] extraction method, e.g. :file_present, :yaml_key,
13
+ # :regex_match, :git_metadata
14
+ attr_reader :method
15
+ # @return [String, nil] a short human-readable justification/excerpt
16
+ attr_reader :detail
17
+ # @return [Time] when the fact was extracted
18
+ attr_reader :detected_at
19
+
20
+ def initialize(source:, method:, detail: nil, detected_at: Time.now)
21
+ @source = source
22
+ @method = method.to_sym
23
+ @detail = detail
24
+ @detected_at = detected_at
25
+ freeze
26
+ end
27
+
28
+ # A one-line, human-facing explanation of the evidence.
29
+ # @return [String]
30
+ def explanation
31
+ base = "#{source} (#{method})"
32
+ detail ? "#{base}: #{detail}" : base
33
+ end
34
+
35
+ def to_h
36
+ {source: source, method: method, detail: detail, detected_at: detected_at.iso8601}
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A typed edge between two repositories (specification section 10). Related
5
+ # projects are a compatibility graph, not isolated automation targets, so an
6
+ # edge is a first-class value: it names *what kind* of dependence exists, and
7
+ # it is what a family failure points at - "the exact edge that broke, not
8
+ # merely the repository that ran CI".
9
+ class Relationship
10
+ TYPES = {
11
+ compatibility_suite: "A separate executable suite that validates this project's public claims.",
12
+ plugin_for: "An integration that must remain compatible with a host project or SDK.",
13
+ deployment_example: "A deployment example that must build against its parent project.",
14
+ portfolio_index: "An index or profile that should reflect active project metadata.",
15
+ shared_library: "A package whose releases trigger downstream compatibility checks.",
16
+ fork_of: "A maintained fork whose unique commits and upstream divergence matter.",
17
+ documentation_for: "Documentation whose examples and links must stay synchronized."
18
+ }.freeze
19
+
20
+ # The member role each downstream relationship implies.
21
+ ROLES = {
22
+ compatibility_suite: :compatibility_suite,
23
+ plugin_for: :plugin,
24
+ deployment_example: :deployment_example,
25
+ portfolio_index: :index,
26
+ shared_library: :library,
27
+ fork_of: :fork,
28
+ documentation_for: :documentation
29
+ }.freeze
30
+
31
+ # @return [String] the downstream repository (the dependent side)
32
+ attr_reader :from
33
+ # @return [String] the upstream repository it depends on
34
+ attr_reader :to
35
+ # @return [Symbol] one of {TYPES}
36
+ attr_reader :type
37
+ # @return [String, nil] why this edge is believed
38
+ attr_reader :evidence
39
+
40
+ def initialize(from:, to:, type:, evidence: nil)
41
+ @type = type.to_sym
42
+ unless TYPES.key?(@type)
43
+ raise UnknownError, "Unknown relationship type #{type.inspect}. Known: #{TYPES.keys.join(", ")}."
44
+ end
45
+
46
+ @from = from
47
+ @to = to
48
+ @evidence = evidence
49
+ freeze
50
+ end
51
+
52
+ def description = TYPES.fetch(type)
53
+
54
+ # The role the dependent side plays inside its family.
55
+ def role = ROLES.fetch(type, :member)
56
+
57
+ # A single, unambiguous name for this edge - what a family failure reports.
58
+ def label = "#{to} -> #{from} (#{type.to_s.tr("_", " ")})"
59
+
60
+ def to_h = {from: from, to: to, type: type, evidence: evidence}.compact
61
+ end
62
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Reanimator
6
+ # Orchestrates one bounded repair, end to end, without writing to the
7
+ # repository (specification section 11 repair rung, section 17 run). The flow
8
+ # is: baseline to find a failing obligation, check the permission model,
9
+ # route to a ready agent, let it propose a patch in an isolated workspace,
10
+ # apply that patch to a *separate* clean copy, re-evaluate independently, and
11
+ # only then materialize a proposed draft PR. Every exit is a recorded {Run}.
12
+ class RepairRun
13
+ REQUIRED_CAPABILITIES = [Agents::Capability::PATCH, Agents::Capability::REPOSITORY_EDIT].freeze
14
+ EDITABLE = ["lib/**", "app/**", "*.rb"].freeze
15
+
16
+ def initialize(contract, provider: :local, task_class: :patch_dependency, trigger: "user request")
17
+ @contract = contract
18
+ @provider = provider.to_sym
19
+ @task_class = task_class.to_sym
20
+ @trigger = trigger
21
+ end
22
+
23
+ # @return [Run]
24
+ def run
25
+ pre = BaselineRunner.new(@contract, execute: true).run
26
+ target = pre.first_failing_required_criterion
27
+ return record(:no_op, pre: pre, reason: "All required criteria pass; no repair is needed.") unless target
28
+
29
+ resolution = PermissionModel.new(@contract).resolve(@task_class)
30
+ unless resolution.permits?(:repair)
31
+ return record(:escalation, pre: pre, objective: target,
32
+ reason: "Repair is not enabled for #{@task_class} (max autonomy #{resolution.max_action}); enable repair to proceed.")
33
+ end
34
+
35
+ decision = route
36
+ unless decision.selected?
37
+ return record(:safe_stop, pre: pre, objective: target, reason: "No ready agent. #{decision.explanation}")
38
+ end
39
+
40
+ attempt(pre, target, decision.profile)
41
+ end
42
+
43
+ private
44
+
45
+ def attempt(pre, target, profile)
46
+ adapter = Agents.for(profile.connection)
47
+ workspace = Isolation.copy(@contract.repository.path)
48
+ begin
49
+ task = build_task(target, workspace, pre)
50
+ result = adapter.repair(task)
51
+ unless result.structured?
52
+ return record(:safe_stop, pre: pre, objective: target, provider: adapter.provider,
53
+ reason: result.message || "The agent produced no structured plan and patch.")
54
+ end
55
+
56
+ breach = result.patch.paths.reject { |path| task.may_edit?(path) }
57
+ unless breach.empty?
58
+ return record(:escalation, pre: pre, objective: target, provider: adapter.provider,
59
+ reason: "Refused: the patch touches protected or out-of-scope paths: #{breach.join(", ")}.")
60
+ end
61
+
62
+ verify(pre, target, adapter, result)
63
+ ensure
64
+ FileUtils.remove_entry(workspace, true)
65
+ end
66
+ end
67
+
68
+ def verify(pre, target, adapter, result)
69
+ post = evaluate_isolated(result.patch)
70
+ if resolved?(target, pre, post)
71
+ change = ProposedChange.new(
72
+ obligation: obligation(target), patch: result.patch, plan: result.plan,
73
+ pre_state: pre.state.to_sym, post_state: post.state.to_sym
74
+ )
75
+ record(:draft_pr, pre: pre, post: post, objective: target, provider: adapter.provider,
76
+ proposed_change: change, cost: result.cost_usd, tools: result.tool_use)
77
+ else
78
+ record(:report, pre: pre, post: post, objective: target, provider: adapter.provider,
79
+ cost: result.cost_usd, reason: "The repair did not independently verify; not proposing a change.")
80
+ end
81
+ end
82
+
83
+ # Apply the patch to a fresh, clean copy and grade it there - never in the
84
+ # agent's workspace (verifier isolation, section 09).
85
+ def evaluate_isolated(patch)
86
+ Isolation.copy(@contract.repository.path) do |eval_dir|
87
+ patch.apply(eval_dir)
88
+ BaselineRunner.new(EffectivePolicyResolver.new(Repository.at(eval_dir)).resolve, execute: true).run
89
+ end
90
+ end
91
+
92
+ def resolved?(target, pre, post)
93
+ return false unless post.result_for(target.id)&.verified?
94
+
95
+ # No criterion that was verified-required before may be unresolved after.
96
+ regressed = pre.verified_required_ids & post.unresolved_required.map { |r| r.criterion.id }
97
+ regressed.empty?
98
+ end
99
+
100
+ # Readiness is *proven*, not assumed: the provider is commissioned against a
101
+ # disposable fixture and its verdict (ready/degraded/incompatible/unavailable)
102
+ # decides eligibility. Only a commissioned-ready provider is routed to; any
103
+ # other verdict fails closed to a safe stop.
104
+ def route
105
+ adapter = Agents.for(connection)
106
+ readiness = Commissioning::Test.new(adapter).run.readiness
107
+ profile = AgentProfile.from_adapter(adapter, readiness: readiness)
108
+ Routing::Router.new([profile]).select(required_capabilities: REQUIRED_CAPABILITIES, task_class: @task_class)
109
+ end
110
+
111
+ def build_task(target, workspace, pre)
112
+ Agents::RepairTask.new(
113
+ objective: target, workspace: workspace, evidence: pre,
114
+ constraints: Agents::Constraints.new(
115
+ allowed_paths: EDITABLE, protected_paths: @contract.protected_paths,
116
+ max_cost_usd: connection.max_cost_usd, code_egress: Agents.for(connection).code_egress
117
+ )
118
+ )
119
+ end
120
+
121
+ def obligation(target)
122
+ Obligation.new(
123
+ repository: @contract.repository, policy_version: @contract.policy_version,
124
+ target: target, trigger: @trigger, task_class: @task_class
125
+ )
126
+ end
127
+
128
+ def connection
129
+ @connection ||=
130
+ if @provider == :local
131
+ AgentConnection.local
132
+ else
133
+ AgentConnection.new(provider: @provider, credentials_ref: Reanimator::CREDENTIAL_REFS[@provider])
134
+ end
135
+ end
136
+
137
+ def record(outcome, pre: nil, post: nil, objective: nil, provider: nil, proposed_change: nil, cost: 0.0, tools: [], reason: nil)
138
+ Run.new(
139
+ outcome: outcome, repository: @contract.repository, policy_version: @contract.policy_version,
140
+ trigger: @trigger, objective: objective, provider: provider, access: {tools: tools},
141
+ pre_state: pre&.state&.to_sym, post_state: post&.state&.to_sym,
142
+ cost_usd: cost, proposed_change: proposed_change, reason: reason
143
+ )
144
+ end
145
+ end
146
+ end