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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +207 -0
- data/LICENSE.txt +21 -0
- data/README.md +483 -0
- data/SECURITY.md +32 -0
- data/exe/reanimator +6 -0
- data/lib/reanimator/acceptance_criterion.rb +75 -0
- data/lib/reanimator/agent_connection.rb +62 -0
- data/lib/reanimator/agent_profile.rb +51 -0
- data/lib/reanimator/agents/adapter.rb +49 -0
- data/lib/reanimator/agents/capability.rb +29 -0
- data/lib/reanimator/agents/constraints.rb +57 -0
- data/lib/reanimator/agents/hosted.rb +38 -0
- data/lib/reanimator/agents/local.rb +46 -0
- data/lib/reanimator/agents/patch.rb +60 -0
- data/lib/reanimator/agents/repair_task.rb +51 -0
- data/lib/reanimator/agents/result.rb +42 -0
- data/lib/reanimator/agents/strategies/arithmetic_fix.rb +51 -0
- data/lib/reanimator/agents/strategies/base.rb +26 -0
- data/lib/reanimator/agents.rb +38 -0
- data/lib/reanimator/autonomy_level.rb +41 -0
- data/lib/reanimator/baseline.rb +106 -0
- data/lib/reanimator/baseline_runner.rb +81 -0
- data/lib/reanimator/bounded_command.rb +87 -0
- data/lib/reanimator/classifier.rb +89 -0
- data/lib/reanimator/cli.rb +273 -0
- data/lib/reanimator/cohort.rb +61 -0
- data/lib/reanimator/commissioning/fixture.rb +69 -0
- data/lib/reanimator/commissioning/report.rb +45 -0
- data/lib/reanimator/commissioning/test.rb +145 -0
- data/lib/reanimator/confidence.rb +66 -0
- data/lib/reanimator/configuration.rb +35 -0
- data/lib/reanimator/contract_file.rb +116 -0
- data/lib/reanimator/contract_snapshot.rb +188 -0
- data/lib/reanimator/criteria_stack.rb +91 -0
- data/lib/reanimator/criterion_result.rb +70 -0
- data/lib/reanimator/detectors/base.rb +49 -0
- data/lib/reanimator/detectors/bundler.rb +68 -0
- data/lib/reanimator/detectors/commands.rb +112 -0
- data/lib/reanimator/detectors/fork.rb +25 -0
- data/lib/reanimator/detectors/github_actions.rb +68 -0
- data/lib/reanimator/detectors/operational_proof.rb +48 -0
- data/lib/reanimator/detectors/rails_app.rb +51 -0
- data/lib/reanimator/detectors/relationships.rb +46 -0
- data/lib/reanimator/detectors/ruby_gem.rb +40 -0
- data/lib/reanimator/detectors/security_controls.rb +36 -0
- data/lib/reanimator/discovered_fact.rb +99 -0
- data/lib/reanimator/discovery.rb +48 -0
- data/lib/reanimator/discovery_result.rb +89 -0
- data/lib/reanimator/drift_detector.rb +227 -0
- data/lib/reanimator/drift_report.rb +86 -0
- data/lib/reanimator/effective_contract.rb +108 -0
- data/lib/reanimator/effective_policy_resolver.rb +116 -0
- data/lib/reanimator/errors.rb +21 -0
- data/lib/reanimator/evaluators/base.rb +55 -0
- data/lib/reanimator/evaluators/command.rb +34 -0
- data/lib/reanimator/evaluators/documentation_check.rb +25 -0
- data/lib/reanimator/evaluators/http_smoke.rb +19 -0
- data/lib/reanimator/evaluators/repository_integrity.rb +22 -0
- data/lib/reanimator/evaluators/security_evidence.rb +24 -0
- data/lib/reanimator/evaluators/support_matrix.rb +25 -0
- data/lib/reanimator/evaluators.rb +41 -0
- data/lib/reanimator/family/gate.rb +40 -0
- data/lib/reanimator/family/gate_result.rb +46 -0
- data/lib/reanimator/family/gate_runner.rb +91 -0
- data/lib/reanimator/family.rb +89 -0
- data/lib/reanimator/family_inference.rb +113 -0
- data/lib/reanimator/git.rb +72 -0
- data/lib/reanimator/isolation.rb +36 -0
- data/lib/reanimator/language_inference.rb +43 -0
- data/lib/reanimator/lifecycle_intent.rb +93 -0
- data/lib/reanimator/obligation.rb +35 -0
- data/lib/reanimator/permission_model.rb +61 -0
- data/lib/reanimator/portfolio.rb +68 -0
- data/lib/reanimator/portfolio_drift.rb +120 -0
- data/lib/reanimator/profile_inference.rb +37 -0
- data/lib/reanimator/profiles.rb +110 -0
- data/lib/reanimator/proof_of_life.rb +45 -0
- data/lib/reanimator/proposed_change.rb +55 -0
- data/lib/reanimator/provenance.rb +39 -0
- data/lib/reanimator/relationship.rb +62 -0
- data/lib/reanimator/repair_run.rb +146 -0
- data/lib/reanimator/reports/agents.rb +28 -0
- data/lib/reanimator/reports/baseline.rb +73 -0
- data/lib/reanimator/reports/commissioning.rb +48 -0
- data/lib/reanimator/reports/contract.rb +70 -0
- data/lib/reanimator/reports/discovery.rb +38 -0
- data/lib/reanimator/reports/drift.rb +63 -0
- data/lib/reanimator/reports/families.rb +41 -0
- data/lib/reanimator/reports/family_gates.rb +41 -0
- data/lib/reanimator/reports/github_check.rb +45 -0
- data/lib/reanimator/reports/inventory.rb +37 -0
- data/lib/reanimator/reports/permissions.rb +32 -0
- data/lib/reanimator/reports/portfolio_baseline.rb +68 -0
- data/lib/reanimator/reports/portfolio_drift.rb +107 -0
- data/lib/reanimator/reports/run.rb +52 -0
- data/lib/reanimator/reports/snapshot.rb +58 -0
- data/lib/reanimator/reports.rb +36 -0
- data/lib/reanimator/repository.rb +96 -0
- data/lib/reanimator/repository_metadata.rb +78 -0
- data/lib/reanimator/repository_state.rb +48 -0
- data/lib/reanimator/routing/decision.rb +35 -0
- data/lib/reanimator/routing/router.rb +122 -0
- data/lib/reanimator/run.rb +48 -0
- data/lib/reanimator/safety_floor.rb +81 -0
- data/lib/reanimator/security_control.rb +87 -0
- data/lib/reanimator/security_posture.rb +80 -0
- data/lib/reanimator/snapshot_store.rb +78 -0
- data/lib/reanimator/task_class.rb +63 -0
- data/lib/reanimator/version.rb +5 -0
- data/lib/reanimator/vitality_profile.rb +44 -0
- data/lib/reanimator/waiver.rb +74 -0
- data/lib/reanimator.rb +214 -0
- 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
|