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,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
class Family
|
|
5
|
+
# Evaluates a family's gates (specification section 10). A candidate change
|
|
6
|
+
# upstream can trigger downstream checks "without opening changes in
|
|
7
|
+
# downstream repositories", so every check runs read-only, bounded, and in
|
|
8
|
+
# a disposable copy of the downstream member - never in its working tree.
|
|
9
|
+
#
|
|
10
|
+
# Execution is opt-in exactly as it is for a baseline: by default the runner
|
|
11
|
+
# reports what it *would* prove, so the graph is useful before anyone grants
|
|
12
|
+
# the right to run anything.
|
|
13
|
+
class GateRunner
|
|
14
|
+
# A downstream member proves itself with its smoke script when it has one
|
|
15
|
+
# (the section 10 example uses bin/smoke), else its discovered tests.
|
|
16
|
+
SMOKE_SCRIPTS = ["bin/smoke", "bin/doctor", "script/smoke"].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :family, :execute, :timeout
|
|
19
|
+
|
|
20
|
+
def initialize(family, execute: false, timeout: nil)
|
|
21
|
+
@family = family
|
|
22
|
+
@execute = execute
|
|
23
|
+
@timeout = timeout || Reanimator.configuration.command_timeout
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Array<GateResult>]
|
|
27
|
+
def run
|
|
28
|
+
family.gates.map { |gate| evaluate(gate) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def evaluate(gate)
|
|
34
|
+
member = family.member(gate.requires_repository)
|
|
35
|
+
return missing(gate, "#{gate.requires_repository} is not a member of family #{family.id}.") unless member
|
|
36
|
+
return missing(gate, "No local checkout known for #{gate.requires_repository}.") unless member.path
|
|
37
|
+
|
|
38
|
+
check = gate.check || discover_check(member)
|
|
39
|
+
return missing(gate, "No proof command found in #{gate.requires_repository}; the edge is unverifiable.") unless check
|
|
40
|
+
|
|
41
|
+
return planned(gate, check) unless execution_allowed?
|
|
42
|
+
|
|
43
|
+
run_check(gate, member, check)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run_check(gate, member, check)
|
|
47
|
+
outcome = Isolation.copy(member.path) do |workspace|
|
|
48
|
+
BoundedCommand.run(check, chdir: workspace, timeout: timeout)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if outcome.success?
|
|
52
|
+
result(gate, :verified, detail: "#{check} passed in #{member.full_name}.", duration: outcome.duration)
|
|
53
|
+
else
|
|
54
|
+
reason = outcome.timed_out? ? "timed out after #{timeout}s" : "failed"
|
|
55
|
+
result(gate, :failed, detail: "#{check} #{reason} in #{member.full_name}.", duration: outcome.duration)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The downstream member's own proof command: a smoke script if present,
|
|
60
|
+
# otherwise whatever discovery says it runs for tests.
|
|
61
|
+
def discover_check(member)
|
|
62
|
+
repository = Repository.at(member.path)
|
|
63
|
+
smoke = SMOKE_SCRIPTS.find { |script| repository.exist?(script) }
|
|
64
|
+
return smoke if smoke
|
|
65
|
+
|
|
66
|
+
Discovery.new(repository).run.discovered_commands[:tests]
|
|
67
|
+
rescue RepositoryError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def execution_allowed?
|
|
72
|
+
execute && Reanimator.configuration.allow_execution
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def planned(gate, check)
|
|
76
|
+
result(gate, :planned, detail: "Would run #{check} in #{gate.requires_repository}.")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def missing(gate, detail)
|
|
80
|
+
result(gate, :missing_evidence, detail: detail)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def result(gate, status, detail: nil, duration: nil)
|
|
84
|
+
GateResult.new(
|
|
85
|
+
gate: gate, status: status, detail: detail, duration: duration,
|
|
86
|
+
relationship: family.relationship_between(gate.on, gate.requires_repository)
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# A graph of repositories whose health must be evaluated together
|
|
5
|
+
# (specification sections 02 and 10) - a gem and its executable examples, an
|
|
6
|
+
# application and its client, a project and its deployment samples. A family
|
|
7
|
+
# is the unit that answers "keep connected projects alive together": it knows
|
|
8
|
+
# its members and their roles, the typed edges between them, and the gates
|
|
9
|
+
# that make one member's proof a condition of another's.
|
|
10
|
+
class Family
|
|
11
|
+
# One repository inside a family. `path` is where it lives locally, when
|
|
12
|
+
# known - a gate can only run a downstream check if it can reach it.
|
|
13
|
+
Member = Struct.new(:full_name, :role, :path) do
|
|
14
|
+
def to_h = {full_name: full_name, role: role, path: path}.compact
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :id, :members, :gates, :relationships
|
|
18
|
+
|
|
19
|
+
def initialize(id:, members: [], gates: [], relationships: [])
|
|
20
|
+
@id = id.to_sym
|
|
21
|
+
@members = members
|
|
22
|
+
@gates = gates
|
|
23
|
+
@relationships = relationships
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Member, nil]
|
|
27
|
+
def member(full_name)
|
|
28
|
+
members.find { |m| m.full_name == full_name }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def member?(full_name) = !member(full_name).nil?
|
|
32
|
+
|
|
33
|
+
def size = members.size
|
|
34
|
+
|
|
35
|
+
# Members holding a given role, e.g. :compatibility_suite.
|
|
36
|
+
def with_role(role)
|
|
37
|
+
members.select { |m| m.role == role.to_sym }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The edge between two members, used to name exactly what broke.
|
|
41
|
+
def relationship_between(upstream, downstream)
|
|
42
|
+
relationships.find { |r| r.to == upstream && r.from == downstream }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Parses the section 10 family-policy shape:
|
|
46
|
+
#
|
|
47
|
+
# family:
|
|
48
|
+
# id: agentic
|
|
49
|
+
# members:
|
|
50
|
+
# codenamev/agentic: {role: library}
|
|
51
|
+
# codenamev/agentic-examples: {role: compatibility-suite}
|
|
52
|
+
# gates:
|
|
53
|
+
# - on: codenamev/agentic
|
|
54
|
+
# require: {repository: codenamev/agentic-examples, check: bin/smoke}
|
|
55
|
+
#
|
|
56
|
+
# @param declaration [Hash]
|
|
57
|
+
# @param paths [Hash{String=>String}] optional full_name => local path
|
|
58
|
+
# @return [Family]
|
|
59
|
+
def self.from_declaration(declaration, paths: {})
|
|
60
|
+
raise ContractError, "A family declaration needs an id." unless declaration[:id]
|
|
61
|
+
|
|
62
|
+
members = (declaration[:members] || {}).map do |full_name, attributes|
|
|
63
|
+
name = full_name.to_s
|
|
64
|
+
role = (attributes || {})[:role] || :member
|
|
65
|
+
Member.new(name, role.to_s.tr("-", "_").to_sym, paths[name])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
gates = (declaration[:gates] || []).map do |gate|
|
|
69
|
+
required = gate[:require] || {}
|
|
70
|
+
Gate.new(
|
|
71
|
+
on: gate[:on].to_s,
|
|
72
|
+
requires_repository: required[:repository].to_s,
|
|
73
|
+
check: required[:check]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
new(id: declaration[:id], members: members, gates: gates)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def to_h
|
|
81
|
+
{
|
|
82
|
+
id: id,
|
|
83
|
+
members: members.map(&:to_h),
|
|
84
|
+
relationships: relationships.map(&:to_h),
|
|
85
|
+
gates: gates.map(&:to_h)
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# Infers repository families from a portfolio (specification section 10).
|
|
5
|
+
# The strongest signals are structural and cheap: a repository named
|
|
6
|
+
# `<project>-examples` is that project's compatibility suite,
|
|
7
|
+
# `<project>-example-heroku` is a deployment example, `<project>-docs` is its
|
|
8
|
+
# documentation, and `<host>-<project>` is a plugin for a project that is
|
|
9
|
+
# itself in the portfolio.
|
|
10
|
+
#
|
|
11
|
+
# Inference is deliberately conservative - it only proposes an edge it can
|
|
12
|
+
# point at concrete evidence for. An owner-declared family always wins, and
|
|
13
|
+
# weaker "these names look related" hunches are left to declaration rather
|
|
14
|
+
# than turned into obligations.
|
|
15
|
+
module FamilyInference
|
|
16
|
+
# Each rule maps a repository name to the project it depends on, and the
|
|
17
|
+
# kind of dependence that implies.
|
|
18
|
+
SUFFIX_RULES = [
|
|
19
|
+
[/\A(?<base>.+?)[-_]examples?\z/, :compatibility_suite],
|
|
20
|
+
[/\A(?<base>.+?)[-_]example[-_].+\z/, :deployment_example],
|
|
21
|
+
[/\A(?<base>.+?)[-_]docs?\z/, :documentation_for]
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# @param portfolio [Portfolio]
|
|
27
|
+
# @return [Array<Family>] families with at least one edge, id-sorted
|
|
28
|
+
def call(portfolio)
|
|
29
|
+
repositories = portfolio.entries.map(&:repository)
|
|
30
|
+
declared = declared_families(repositories)
|
|
31
|
+
inferred = inferred_families(repositories, declared)
|
|
32
|
+
|
|
33
|
+
(declared.values + inferred.values).sort_by { |family| family.id.to_s }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Families a repository explicitly joined via `family: {id:, role:}`.
|
|
37
|
+
def declared_families(repositories)
|
|
38
|
+
repositories.each_with_object({}) do |repository, families|
|
|
39
|
+
declaration = repository.declared&.family
|
|
40
|
+
next unless declaration.is_a?(Hash) && declaration[:id]
|
|
41
|
+
|
|
42
|
+
id = declaration[:id].to_sym
|
|
43
|
+
role = (declaration[:role] || :member).to_s.tr("-", "_").to_sym
|
|
44
|
+
family = families[id] ||= Family.new(id: id)
|
|
45
|
+
family.members << Family::Member.new(repository.full_name, role, repository.path)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Families implied by naming structure, skipping any repository that has
|
|
50
|
+
# already declared its membership.
|
|
51
|
+
def inferred_families(repositories, declared)
|
|
52
|
+
claimed = declared.values.flat_map { |f| f.members.map(&:full_name) }
|
|
53
|
+
by_name = repositories.each_with_object({}) { |r, index| index[r.name] = r }
|
|
54
|
+
by_full_name = repositories.each_with_object({}) { |r, index| index[r.full_name] = r }
|
|
55
|
+
|
|
56
|
+
edges = repositories.filter_map do |repository|
|
|
57
|
+
next if claimed.include?(repository.full_name)
|
|
58
|
+
|
|
59
|
+
relationship = relate(repository, by_name)
|
|
60
|
+
# Declaration wins over inference on *both* ends: once an owner has
|
|
61
|
+
# placed a repository in a family, we do not invent a competing family
|
|
62
|
+
# around it, even as the upstream of someone else's edge.
|
|
63
|
+
next if relationship.nil? || claimed.include?(relationship.to)
|
|
64
|
+
|
|
65
|
+
relationship
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
build_families(edges, by_full_name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The edge this repository implies, or nil when nothing matches.
|
|
72
|
+
def relate(repository, by_name)
|
|
73
|
+
SUFFIX_RULES.each do |pattern, type|
|
|
74
|
+
match = repository.name.match(pattern)
|
|
75
|
+
next unless match && by_name.key?(match[:base])
|
|
76
|
+
|
|
77
|
+
return edge(repository, by_name[match[:base]], type, "name #{repository.name} implies #{match[:base]}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
plugin_edge(repository, by_name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# `ruby_llm-ups` is a plugin for `ups` when `ups` is itself in the portfolio.
|
|
84
|
+
def plugin_edge(repository, by_name)
|
|
85
|
+
host = repository.name.split(/[-_]/).last
|
|
86
|
+
return if host.nil? || host == repository.name
|
|
87
|
+
return unless by_name.key?(host)
|
|
88
|
+
|
|
89
|
+
edge(repository, by_name[host], :plugin_for, "name #{repository.name} targets #{host}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def edge(dependent, upstream, type, evidence)
|
|
93
|
+
return if dependent.full_name == upstream.full_name
|
|
94
|
+
|
|
95
|
+
Relationship.new(from: dependent.full_name, to: upstream.full_name, type: type, evidence: evidence)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Groups edges by their upstream project into one family each, with a gate
|
|
99
|
+
# per edge so every dependence has something that proves it.
|
|
100
|
+
def build_families(edges, by_full_name)
|
|
101
|
+
edges.group_by(&:to).each_with_object({}) do |(upstream_name, group), families|
|
|
102
|
+
upstream = by_full_name.fetch(upstream_name)
|
|
103
|
+
|
|
104
|
+
members = [Family::Member.new(upstream_name, :library, upstream.path)] +
|
|
105
|
+
group.map { |edge| Family::Member.new(edge.from, edge.role, by_full_name[edge.from]&.path) }
|
|
106
|
+
gates = group.map { |edge| Family::Gate.new(on: upstream_name, requires_repository: edge.from) }
|
|
107
|
+
|
|
108
|
+
families[upstream.name.to_sym] =
|
|
109
|
+
Family.new(id: upstream.name, members: members, gates: gates, relationships: group)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Reanimator
|
|
6
|
+
# A deliberately tiny, read-only wrapper around the git CLI. Reanimator
|
|
7
|
+
# never mutates a repository during observation or discovery, so this helper
|
|
8
|
+
# only ever runs inspection commands and returns nil rather than raising when
|
|
9
|
+
# git is absent or the command fails - a repository without git history is a
|
|
10
|
+
# fact to record, not a crash.
|
|
11
|
+
module Git
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# @param root [String] repository working directory
|
|
15
|
+
# @return [Boolean] whether the path is inside a git working tree
|
|
16
|
+
def repository?(root)
|
|
17
|
+
capture(root, "rev-parse", "--is-inside-work-tree") == "true"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [String, nil] the origin remote URL, if any
|
|
21
|
+
def origin_url(root)
|
|
22
|
+
capture(root, "config", "--get", "remote.origin.url")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] whether an "upstream" remote is configured, a strong
|
|
26
|
+
# local hint that the repository is a maintained fork
|
|
27
|
+
def upstream_remote?(root)
|
|
28
|
+
!capture(root, "config", "--get", "remote.upstream.url").nil?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [String, nil] the current branch name
|
|
32
|
+
def current_branch(root)
|
|
33
|
+
branch = capture(root, "rev-parse", "--abbrev-ref", "HEAD")
|
|
34
|
+
branch unless branch == "HEAD"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Time, nil] author time of the most recent commit
|
|
38
|
+
def last_commit_at(root)
|
|
39
|
+
iso = capture(root, "log", "-1", "--format=%cI")
|
|
40
|
+
Time.parse(iso) if iso && !iso.empty?
|
|
41
|
+
rescue ArgumentError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Integer, nil] number of commits, a cheap activity signal
|
|
46
|
+
def commit_count(root)
|
|
47
|
+
count = capture(root, "rev-list", "--count", "HEAD")
|
|
48
|
+
Integer(count) if count
|
|
49
|
+
rescue ArgumentError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @return [Array<String>] tracked files (fast, respects .gitignore); empty
|
|
54
|
+
# when git is unavailable
|
|
55
|
+
def tracked_files(root)
|
|
56
|
+
out = capture(root, "ls-files")
|
|
57
|
+
out ? out.split("\n") : []
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Runs a read-only git command, returning trimmed stdout or nil on any
|
|
61
|
+
# failure. stderr is discarded - callers treat "no answer" as the signal.
|
|
62
|
+
def capture(root, *args)
|
|
63
|
+
require "time"
|
|
64
|
+
stdout, _stderr, status = Open3.capture3("git", "-C", root.to_s, *args)
|
|
65
|
+
return nil unless status.success?
|
|
66
|
+
value = stdout.strip
|
|
67
|
+
value.empty? ? nil : value
|
|
68
|
+
rescue Errno::ENOENT
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Reanimator
|
|
7
|
+
# Disposable-workspace helper (specification section 12: "disposable
|
|
8
|
+
# workspaces", section 14: "all commands run in a disposable workspace"). It
|
|
9
|
+
# copies a repository into a throwaway directory so an agent can work, and so
|
|
10
|
+
# evaluation can run, without ever touching the original source. The agent's
|
|
11
|
+
# workspace and the evaluation workspace are always distinct copies - verifier
|
|
12
|
+
# isolation by construction.
|
|
13
|
+
module Isolation
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Copies a repository root into a fresh temp directory (excluding .git),
|
|
17
|
+
# yielding the copy to the block and removing it afterward. Without a block
|
|
18
|
+
# it returns the path and the caller owns cleanup.
|
|
19
|
+
def copy(root)
|
|
20
|
+
destination = Dir.mktmpdir("reanimator-iso")
|
|
21
|
+
Dir.children(root).each do |child|
|
|
22
|
+
next if child == ".git"
|
|
23
|
+
|
|
24
|
+
FileUtils.cp_r(File.join(root, child), File.join(destination, child))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
return destination unless block_given?
|
|
28
|
+
|
|
29
|
+
begin
|
|
30
|
+
yield destination
|
|
31
|
+
ensure
|
|
32
|
+
FileUtils.remove_entry(destination, true)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# A cheap primary-language guess from file extensions. This is intentionally
|
|
5
|
+
# crude - it exists to seed cohort heuristics and reports, not to replace a
|
|
6
|
+
# linguist. When git is present it counts tracked files (fast and respectful
|
|
7
|
+
# of .gitignore); otherwise it falls back to a bounded directory walk.
|
|
8
|
+
module LanguageInference
|
|
9
|
+
EXTENSIONS = {
|
|
10
|
+
".rb" => "Ruby", ".rake" => "Ruby", ".gemspec" => "Ruby",
|
|
11
|
+
".js" => "JavaScript", ".mjs" => "JavaScript", ".ts" => "TypeScript",
|
|
12
|
+
".py" => "Python", ".go" => "Go", ".rs" => "Rust",
|
|
13
|
+
".java" => "Java", ".kt" => "Kotlin", ".c" => "C", ".h" => "C",
|
|
14
|
+
".cpp" => "C++", ".cs" => "C#", ".php" => "PHP",
|
|
15
|
+
".ex" => "Elixir", ".exs" => "Elixir", ".erl" => "Erlang",
|
|
16
|
+
".swift" => "Swift", ".sh" => "Shell", ".crystal" => "Crystal", ".cr" => "Crystal"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
MAX_WALK_FILES = 5_000
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @param root [String]
|
|
24
|
+
# @return [String, nil] the most common recognized language, or nil
|
|
25
|
+
def call(root)
|
|
26
|
+
counts = Hash.new(0)
|
|
27
|
+
files(root).each do |rel|
|
|
28
|
+
language = EXTENSIONS[File.extname(rel)]
|
|
29
|
+
counts[language] += 1 if language
|
|
30
|
+
end
|
|
31
|
+
counts.max_by { |_lang, n| n }&.first
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def files(root)
|
|
35
|
+
tracked = Git.tracked_files(root)
|
|
36
|
+
return tracked unless tracked.empty?
|
|
37
|
+
|
|
38
|
+
Dir.glob("**/*", base: root, flags: File::FNM_DOTMATCH)
|
|
39
|
+
.reject { |rel| rel.start_with?(".git/") }
|
|
40
|
+
.first(MAX_WALK_FILES)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# The first and most important configuration value (specification section
|
|
5
|
+
# 05). Lifecycle intent is the owner's declaration of *why* a repository
|
|
6
|
+
# should continue to exist and what level of care it deserves. The same
|
|
7
|
+
# technical signal - say, a failing test - means different things for a
|
|
8
|
+
# maintained product, a preserved artifact, and an ignored fork, so intent
|
|
9
|
+
# is never inferred as final without confirmation.
|
|
10
|
+
class LifecycleIntent
|
|
11
|
+
# @return [Symbol] stable key used in contracts and reports
|
|
12
|
+
attr_reader :key
|
|
13
|
+
# @return [String] the promise Reanimator makes for this intent
|
|
14
|
+
attr_reader :promise
|
|
15
|
+
# @return [String] the default activity this intent authorizes
|
|
16
|
+
attr_reader :default_activity
|
|
17
|
+
# @return [Boolean] whether the intent creates any stewardship obligation
|
|
18
|
+
attr_reader :creates_obligation
|
|
19
|
+
|
|
20
|
+
def initialize(key:, promise:, default_activity:, creates_obligation: true)
|
|
21
|
+
@key = key
|
|
22
|
+
@promise = promise
|
|
23
|
+
@default_activity = default_activity
|
|
24
|
+
@creates_obligation = creates_obligation
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] whether the intent creates any stewardship obligation
|
|
29
|
+
def creates_obligation?
|
|
30
|
+
@creates_obligation
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The canonical intents, keyed by symbol. Order is meaningful for display:
|
|
34
|
+
# most-active first, dormant last.
|
|
35
|
+
ALL = {
|
|
36
|
+
maintain: new(
|
|
37
|
+
key: :maintain,
|
|
38
|
+
promise: "Keep the repository runnable, supported, secure, and within its declared contract.",
|
|
39
|
+
default_activity: "Scheduled verification and eligible repair."
|
|
40
|
+
),
|
|
41
|
+
monitor: new(
|
|
42
|
+
key: :monitor,
|
|
43
|
+
promise: "Detect meaningful deterioration without routine code churn.",
|
|
44
|
+
default_activity: "Periodic checks and event-triggered diagnosis."
|
|
45
|
+
),
|
|
46
|
+
preserve: new(
|
|
47
|
+
key: :preserve,
|
|
48
|
+
promise: "Protect historical readability, provenance, and known reproduction instructions.",
|
|
49
|
+
default_activity: "Link, license, metadata, and optional reproducibility checks."
|
|
50
|
+
),
|
|
51
|
+
incubate: new(
|
|
52
|
+
key: :incubate,
|
|
53
|
+
promise: "Surface missing foundations while the project is still becoming real.",
|
|
54
|
+
default_activity: "Report gaps; avoid unsolicited modernization."
|
|
55
|
+
),
|
|
56
|
+
retire: new(
|
|
57
|
+
key: :retire,
|
|
58
|
+
promise: "Make end-of-life explicit and safely reduce obligations.",
|
|
59
|
+
default_activity: "Archive checklist, notices, migration path, and access cleanup."
|
|
60
|
+
),
|
|
61
|
+
upstream: new(
|
|
62
|
+
key: :upstream,
|
|
63
|
+
promise: "Track a fork's divergence and upstream movement.",
|
|
64
|
+
default_activity: "Compare ancestry and unique commits; no generic dependency churn."
|
|
65
|
+
),
|
|
66
|
+
ignore: new(
|
|
67
|
+
key: :ignore,
|
|
68
|
+
promise: "Create no stewardship obligation.",
|
|
69
|
+
default_activity: "No scheduled activity.",
|
|
70
|
+
creates_obligation: false
|
|
71
|
+
)
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
74
|
+
# @param key [Symbol, String]
|
|
75
|
+
# @return [LifecycleIntent]
|
|
76
|
+
# @raise [UnknownError] when the key is not a canonical intent
|
|
77
|
+
def self.fetch(key)
|
|
78
|
+
ALL.fetch(key.to_sym) do
|
|
79
|
+
raise UnknownError, "Unknown lifecycle intent #{key.inspect}. Known: #{ALL.keys.join(", ")}."
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param key [Symbol, String]
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def self.valid?(key)
|
|
86
|
+
ALL.key?(key.to_sym)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def to_sym = key
|
|
90
|
+
|
|
91
|
+
def to_s = key.to_s
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# A bounded unit of drift or risk that must be verified, repaired, accepted,
|
|
5
|
+
# deferred, or waived (specification section 02 and section 17). An obligation
|
|
6
|
+
# is idempotent and deduplicated by repository, policy, target, and drift
|
|
7
|
+
# condition, so the same failing criterion does not spawn a swarm of tasks.
|
|
8
|
+
class Obligation
|
|
9
|
+
STATES = %i[open in_progress resolved deferred waived].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :repository, :policy_version, :target, :trigger, :urgency, :task_class, :state
|
|
12
|
+
|
|
13
|
+
def initialize(repository:, policy_version:, target:, trigger:, task_class:, urgency: :normal, state: :open)
|
|
14
|
+
@repository = repository
|
|
15
|
+
@policy_version = policy_version
|
|
16
|
+
@target = target
|
|
17
|
+
@trigger = trigger
|
|
18
|
+
@task_class = task_class
|
|
19
|
+
@urgency = urgency
|
|
20
|
+
@state = state
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Stable identity used for idempotent deduplication (section 17).
|
|
24
|
+
def id
|
|
25
|
+
"#{repository.full_name}::#{policy_version}::#{target.id}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
id: id, repository: repository.full_name, policy_version: policy_version,
|
|
31
|
+
target: target.id, trigger: trigger, task_class: task_class, urgency: urgency, state: state
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# Resolves what an agent is actually permitted to do for a task, by
|
|
5
|
+
# intersecting the task class defaults (section 11) with the repository's
|
|
6
|
+
# enabled autonomy (the effective contract) and the safety floor. The contract
|
|
7
|
+
# is a *ceiling*: a task class may default to "submit as draft", but if the
|
|
8
|
+
# repository has not enabled submit, the resolved action is off. Merge is
|
|
9
|
+
# always off in v1.
|
|
10
|
+
class PermissionModel
|
|
11
|
+
# The resolved decision for one task against one contract.
|
|
12
|
+
Resolution = Struct.new(:task_class, :actions, :elevated_review, :max_action) do
|
|
13
|
+
def elevated_review? = elevated_review
|
|
14
|
+
|
|
15
|
+
def permits?(level) = actions.fetch(level.to_sym, :off) != :off
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(contract)
|
|
19
|
+
@contract = contract
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param task_class [Symbol, TaskClass]
|
|
23
|
+
# @return [Resolution]
|
|
24
|
+
def resolve(task_class)
|
|
25
|
+
klass = task_class.is_a?(TaskClass) ? task_class : TaskClass.fetch(task_class)
|
|
26
|
+
actions = AutonomyLevel.keys.each_with_object({}) do |level, resolved|
|
|
27
|
+
resolved[level] = clamp(level, klass.default_for(level))
|
|
28
|
+
end
|
|
29
|
+
actions[:merge] = :off # safety floor: no automatic merge in v1
|
|
30
|
+
|
|
31
|
+
Resolution.new(
|
|
32
|
+
task_class: klass.key,
|
|
33
|
+
actions: actions,
|
|
34
|
+
elevated_review: klass.elevated_review? || touches_verifier?,
|
|
35
|
+
max_action: highest_enabled(actions)
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Clamp a task-class default against what the contract enables for the rung.
|
|
42
|
+
def clamp(level, default)
|
|
43
|
+
return default if %i[observe diagnose].include?(level) # always at least observe/diagnose
|
|
44
|
+
|
|
45
|
+
ceiling = @contract.autonomy[level]
|
|
46
|
+
disabled?(ceiling) ? :off : default
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def disabled?(ceiling)
|
|
50
|
+
ceiling.nil? || ceiling.to_sym == :disabled
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def touches_verifier?
|
|
54
|
+
@contract.verifier_criteria.any?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def highest_enabled(actions)
|
|
58
|
+
AutonomyLevel.keys.reverse.find { |level| actions[level] != :off } || :observe
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|