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,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Reports
|
|
5
|
+
# Drift across a portfolio, rendered as a triage list rather than a pile of
|
|
6
|
+
# diffs. The ordering is the point: repositories that need a human come
|
|
7
|
+
# first and carry the reason for each policy change, repositories that
|
|
8
|
+
# merely changed follow as information, and repositories with nothing
|
|
9
|
+
# recorded are named rather than quietly counted as fine.
|
|
10
|
+
class PortfolioDrift
|
|
11
|
+
def initialize(drift)
|
|
12
|
+
@drift = drift
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
([banner] + sections + [verdict, footer]).compact.join("\n\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def banner
|
|
22
|
+
[
|
|
23
|
+
"REANIMATOR DRIFT",
|
|
24
|
+
"Portfolio: #{@drift.name}",
|
|
25
|
+
"Generated: #{@drift.generated_at.strftime("%Y-%m-%d %H:%M %Z")}",
|
|
26
|
+
"Mode: read-only"
|
|
27
|
+
].join("\n")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def sections
|
|
31
|
+
[
|
|
32
|
+
review_section,
|
|
33
|
+
listing("CHANGED, BENIGN", @drift.benign) { |entry| change_lines(entry.report.benign_changes, "·", 4) },
|
|
34
|
+
listing("UNCHANGED", @drift.unchanged),
|
|
35
|
+
listing("NOT RECORDED", @drift.unrecorded) { [" nothing to compare against yet"] },
|
|
36
|
+
listing("COULD NOT READ", @drift.failed) { |entry| [" #{entry.error}"] }
|
|
37
|
+
].compact
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The only section that spells out *why* each change matters: these are
|
|
41
|
+
# the ones somebody has to decide about.
|
|
42
|
+
def review_section
|
|
43
|
+
listing("REVIEW REQUIRED", @drift.policy_changed) do |entry|
|
|
44
|
+
changes = entry.report.policy_changes
|
|
45
|
+
[" #{Reports.count(changes.size, "policy change")}"] + change_lines(changes, "!", 6, explain: true)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def listing(title, entries)
|
|
50
|
+
return if entries.empty?
|
|
51
|
+
|
|
52
|
+
lines = ["#{title} — #{Reports.count(entries.size, "repository", "repositories")}"]
|
|
53
|
+
entries.each do |entry|
|
|
54
|
+
lines << " #{entry.name}"
|
|
55
|
+
lines.concat(block_given? ? yield(entry) : [])
|
|
56
|
+
end
|
|
57
|
+
lines.join("\n")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def change_lines(changes, marker, indent, explain: false)
|
|
61
|
+
pad = " " * indent
|
|
62
|
+
changes.flat_map do |change|
|
|
63
|
+
lines = ["#{pad}#{marker} #{change.summary}"]
|
|
64
|
+
lines << "#{pad} #{change.explanation}" if explain
|
|
65
|
+
lines
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Two separate reasons a portfolio can need a human, reported separately
|
|
70
|
+
# because they call for different things: drift somebody has to approve,
|
|
71
|
+
# and a snapshot nobody can read.
|
|
72
|
+
def verdict
|
|
73
|
+
return "No policy changes across the portfolio; nothing here needs a decision." unless @drift.requires_review?
|
|
74
|
+
|
|
75
|
+
[policy_verdict, failure_verdict].compact.join("\n\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def policy_verdict
|
|
79
|
+
repositories = @drift.policy_changed
|
|
80
|
+
return if repositories.empty?
|
|
81
|
+
|
|
82
|
+
total = repositories.sum { |entry| entry.report.policy_changes.size }
|
|
83
|
+
"REVIEW REQUIRED: #{Reports.count(total, "policy change")} across " \
|
|
84
|
+
"#{Reports.count(repositories.size, "repository", "repositories")} — less is being proven than before.\n" \
|
|
85
|
+
"Intended? Re-record with: #{@drift.store.record_command}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def failure_verdict
|
|
89
|
+
failures = @drift.failed
|
|
90
|
+
return if failures.empty?
|
|
91
|
+
|
|
92
|
+
"REVIEW REQUIRED: #{Reports.count(failures.size, "repository", "repositories")} could not be read, " \
|
|
93
|
+
"so drift could not be ruled out there."
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The read-only promise, stated precisely. Recording writes snapshot
|
|
97
|
+
# files and nothing else, so the report says which it did rather than
|
|
98
|
+
# claiming nothing happened.
|
|
99
|
+
def footer
|
|
100
|
+
recorded = @drift.entries.count { |entry| entry.recorded_to }
|
|
101
|
+
return "NO FILES WERE WRITTEN. NO SOURCE CHANGES WERE MADE." if recorded.zero?
|
|
102
|
+
|
|
103
|
+
"Recorded #{Reports.count(recorded, "snapshot")}. NO SOURCE CHANGES WERE MADE."
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Reports
|
|
5
|
+
# Renders a run ledger entry (specification section 17). It leads with the
|
|
6
|
+
# outcome, records the agent and policy version, and - for a proposed repair
|
|
7
|
+
# - shows the draft PR that was produced but not submitted, reaffirming that
|
|
8
|
+
# no source was written.
|
|
9
|
+
class Run
|
|
10
|
+
OUTCOME_LABEL = {
|
|
11
|
+
no_op: "NO-OP (healthy)",
|
|
12
|
+
report: "REPORT",
|
|
13
|
+
draft_pr: "DRAFT PR (proposed, not submitted)",
|
|
14
|
+
escalation: "ESCALATION",
|
|
15
|
+
safe_stop: "SAFE STOP"
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(run)
|
|
19
|
+
@run = run
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def render
|
|
23
|
+
lines = [
|
|
24
|
+
"Run — #{@run.repository.full_name}",
|
|
25
|
+
"Outcome: #{OUTCOME_LABEL.fetch(@run.outcome, @run.outcome.to_s.upcase)}",
|
|
26
|
+
"Trigger: #{@run.trigger}",
|
|
27
|
+
"Policy: #{@run.policy_version}"
|
|
28
|
+
]
|
|
29
|
+
lines << "Objective: #{@run.objective.id} — #{@run.objective.claim}" if @run.objective
|
|
30
|
+
lines << "Agent: #{@run.provider}" if @run.provider
|
|
31
|
+
lines << "Evidence: #{@run.pre_state} → #{@run.post_state}" if @run.post_state
|
|
32
|
+
lines << "Reason: #{@run.reason}" if @run.reason
|
|
33
|
+
lines.concat(proposed_change_lines) if @run.proposed_change
|
|
34
|
+
lines << "NO SOURCE WAS WRITTEN TO THE REPOSITORY."
|
|
35
|
+
lines.join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def proposed_change_lines
|
|
41
|
+
change = @run.proposed_change
|
|
42
|
+
[
|
|
43
|
+
"",
|
|
44
|
+
"Proposed draft PR:",
|
|
45
|
+
" branch: #{change.branch}",
|
|
46
|
+
" title: #{change.title}",
|
|
47
|
+
" files: #{change.patch.paths.join(", ")}"
|
|
48
|
+
]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Reports
|
|
5
|
+
# Renders a recorded contract snapshot for a person rather than a machine:
|
|
6
|
+
# what this repository is currently required to prove, and how to put that
|
|
7
|
+
# on record so later drift has something to be measured against.
|
|
8
|
+
class Snapshot
|
|
9
|
+
def initialize(snapshot, saved_to: nil, record_with: "reanimator drift --record")
|
|
10
|
+
@snapshot = snapshot
|
|
11
|
+
@saved_to = saved_to
|
|
12
|
+
@record_with = record_with
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render
|
|
16
|
+
([header] + [policy, proves, evidence].compact + [footer]).join("\n")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def header
|
|
22
|
+
"Contract snapshot — #{@snapshot.repository}\nTaken: #{@snapshot.taken_at}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def policy
|
|
26
|
+
"Intent: #{@snapshot.intent} Profile: #{@snapshot.profile} " \
|
|
27
|
+
"Security: #{@snapshot.security_posture} posture"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def proves
|
|
31
|
+
required = @snapshot.criteria.count { |criterion| criterion[:required] }
|
|
32
|
+
line = "Proves: #{Reports.count(@snapshot.criteria.size, "criterion", "criteria")} (#{required} required)"
|
|
33
|
+
return line if @snapshot.waivers.empty?
|
|
34
|
+
|
|
35
|
+
"#{line}, #{Reports.count(@snapshot.waivers.size, "accepted risk")}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The evidence half of the snapshot: the facts that drift can narrow
|
|
39
|
+
# without anyone editing .reanimator.yml.
|
|
40
|
+
def evidence
|
|
41
|
+
parts = []
|
|
42
|
+
parts << "runtimes #{@snapshot.support_matrix.join(", ")}" if @snapshot.support_matrix.any?
|
|
43
|
+
parts << "security gates #{@snapshot.security_controls.join(", ")}" if @snapshot.security_controls.any?
|
|
44
|
+
"Evidence: #{parts.join(" · ")}" if parts.any?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whichever way this snapshot was recorded, the next thing a maintainer
|
|
48
|
+
# wants is the command that compares against it - so the footer names
|
|
49
|
+
# that, not the flag they happened to use.
|
|
50
|
+
def footer
|
|
51
|
+
return "\nRecorded to #{@saved_to}. Compare later with: reanimator drift" if @saved_to
|
|
52
|
+
|
|
53
|
+
"\nNothing was written. Record this with `#{@record_with}` so later drift " \
|
|
54
|
+
"has something to be measured against."
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# Plain-text reporters. They build deterministic strings with no color or
|
|
5
|
+
# TTY dependency so they are trivially testable; the CLI layers on boxes and
|
|
6
|
+
# color when a human is watching. The output style mirrors the specification's
|
|
7
|
+
# own artifacts (the section 15 GitHub check and the Appendix A baseline).
|
|
8
|
+
module Reports
|
|
9
|
+
# Status glyphs shared across reporters.
|
|
10
|
+
GLYPH = {
|
|
11
|
+
verified: "✓",
|
|
12
|
+
failed: "✗",
|
|
13
|
+
missing_evidence: "•",
|
|
14
|
+
skipped: "◦",
|
|
15
|
+
accepted_risk: "!",
|
|
16
|
+
planned: "…",
|
|
17
|
+
error: "✗"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def glyph(status) = GLYPH.fetch(status.to_sym, "·")
|
|
23
|
+
|
|
24
|
+
# Human-readable label for a result status.
|
|
25
|
+
def status_label(status)
|
|
26
|
+
status.to_s.tr("_", " ")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# "1 repository" / "3 repositories". Every reporter needs to put a number
|
|
30
|
+
# in front of a noun, and each had grown its own spelling of it - which is
|
|
31
|
+
# how a user eventually reads "1 repositorys".
|
|
32
|
+
def count(size, singular, plural = "#{singular}s")
|
|
33
|
+
"#{size} #{(size == 1) ? singular : plural}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# A single repository under (potential) stewardship: its location, its
|
|
5
|
+
# read-only metadata, any owner-declared contract, and the lifecycle intent
|
|
6
|
+
# currently assigned to it. Detectors read a repository through the file
|
|
7
|
+
# helpers here so they never reach outside its root, and so a future
|
|
8
|
+
# non-filesystem source could satisfy the same surface.
|
|
9
|
+
class Repository
|
|
10
|
+
# @return [String] absolute path to the working tree
|
|
11
|
+
attr_reader :path
|
|
12
|
+
# @return [RepositoryMetadata]
|
|
13
|
+
attr_reader :metadata
|
|
14
|
+
# @return [ContractFile, nil] parsed .reanimator.yml, when present
|
|
15
|
+
attr_reader :declared
|
|
16
|
+
# @return [Symbol, nil] assigned lifecycle intent (mutable during onboarding)
|
|
17
|
+
attr_accessor :intent
|
|
18
|
+
|
|
19
|
+
# Builds a repository from a path on disk.
|
|
20
|
+
# @raise [RepositoryError] when the path is not a directory
|
|
21
|
+
def self.at(path)
|
|
22
|
+
root = File.expand_path(path.to_s)
|
|
23
|
+
unless File.directory?(root)
|
|
24
|
+
raise RepositoryError, "#{path.inspect} is not a directory."
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
declared = ContractFile.load(root)
|
|
28
|
+
new(
|
|
29
|
+
path: root,
|
|
30
|
+
metadata: RepositoryMetadata.from_local(root),
|
|
31
|
+
declared: declared,
|
|
32
|
+
intent: declared&.intent
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(path:, metadata:, declared: nil, intent: nil)
|
|
37
|
+
@path = path
|
|
38
|
+
@metadata = metadata
|
|
39
|
+
@declared = declared
|
|
40
|
+
@intent = intent
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def name = metadata.name
|
|
44
|
+
|
|
45
|
+
def full_name = metadata.full_name || metadata.name
|
|
46
|
+
|
|
47
|
+
def fork? = metadata.fork?
|
|
48
|
+
|
|
49
|
+
# Whether the owner committed a contract file. When true, the declared
|
|
50
|
+
# profile and intent win over inference (section 06 conflict resolution).
|
|
51
|
+
def declared? = !declared.nil?
|
|
52
|
+
|
|
53
|
+
# @return [Symbol, nil] the profile the owner declared, if any
|
|
54
|
+
def declared_profile = declared&.profile
|
|
55
|
+
|
|
56
|
+
# --- read-only file access used by detectors -----------------------------
|
|
57
|
+
|
|
58
|
+
# @return [String] absolute path for a repository-relative path
|
|
59
|
+
def absolute(relative) = File.join(path, relative)
|
|
60
|
+
|
|
61
|
+
# @return [Boolean] whether a repository-relative path exists
|
|
62
|
+
def exist?(relative) = File.exist?(absolute(relative))
|
|
63
|
+
|
|
64
|
+
# @return [Array<String>] repository-relative paths matching a glob
|
|
65
|
+
def glob(pattern)
|
|
66
|
+
Dir.glob(File.join(path, pattern), File::FNM_DOTMATCH)
|
|
67
|
+
.map { |absolute| absolute.delete_prefix("#{path}/") }
|
|
68
|
+
.sort
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Boolean] whether any path matches the glob
|
|
72
|
+
def any?(pattern) = !glob(pattern).empty?
|
|
73
|
+
|
|
74
|
+
# @return [String, nil] contents of a repository-relative file, or nil.
|
|
75
|
+
# Repository content is untrusted and may not be valid UTF-8; it is read
|
|
76
|
+
# as UTF-8 and scrubbed so a detector's regex or slice can never raise on
|
|
77
|
+
# a stray byte (which would otherwise be swallowed as a "detector failed"
|
|
78
|
+
# and silently drop facts), including under a C/US-ASCII locale.
|
|
79
|
+
def read(relative)
|
|
80
|
+
return unless exist?(relative)
|
|
81
|
+
|
|
82
|
+
File.read(absolute(relative), encoding: "UTF-8").scrub
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def to_h
|
|
86
|
+
{
|
|
87
|
+
name: name,
|
|
88
|
+
full_name: full_name,
|
|
89
|
+
path: path,
|
|
90
|
+
intent: intent,
|
|
91
|
+
declared: declared?,
|
|
92
|
+
metadata: metadata.to_h
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# The read-only facts about a repository that exist before any detector runs
|
|
5
|
+
# (specification section 04 inventory inputs): name, ownership, default
|
|
6
|
+
# branch, primary language, fork status, and last activity. This object is
|
|
7
|
+
# the seam between "where the repository lives" and "what it is": a local
|
|
8
|
+
# working tree fills it from git today, and a GitHub inventory source can
|
|
9
|
+
# fill the very same shape tomorrow without touching the rest of the system.
|
|
10
|
+
class RepositoryMetadata
|
|
11
|
+
attr_reader :name, :full_name, :default_branch, :primary_language,
|
|
12
|
+
:last_activity, :commit_count, :topics
|
|
13
|
+
|
|
14
|
+
def initialize(name:, full_name: nil, default_branch: nil, primary_language: nil,
|
|
15
|
+
fork: false, archived: false, last_activity: nil, commit_count: nil, topics: [], has_git: false)
|
|
16
|
+
@name = name
|
|
17
|
+
@full_name = full_name
|
|
18
|
+
@default_branch = default_branch
|
|
19
|
+
@primary_language = primary_language
|
|
20
|
+
@fork = fork
|
|
21
|
+
@archived = archived
|
|
22
|
+
@last_activity = last_activity
|
|
23
|
+
@commit_count = commit_count
|
|
24
|
+
@topics = topics
|
|
25
|
+
@has_git = has_git
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def fork? = @fork
|
|
29
|
+
|
|
30
|
+
def archived? = @archived
|
|
31
|
+
|
|
32
|
+
def has_git? = @has_git
|
|
33
|
+
|
|
34
|
+
# Days since the last commit, or nil when unknown. Feeds the "old
|
|
35
|
+
# unarchived repository" and "recent experiment" cohort heuristics.
|
|
36
|
+
def days_since_activity(now: Time.now)
|
|
37
|
+
return nil unless last_activity
|
|
38
|
+
((now - last_activity) / 86_400).floor
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Builds metadata from a local working tree. Everything is best-effort:
|
|
42
|
+
# a directory that is not a git repository still yields a usable object.
|
|
43
|
+
def self.from_local(root)
|
|
44
|
+
has_git = Git.repository?(root)
|
|
45
|
+
origin = has_git ? Git.origin_url(root) : nil
|
|
46
|
+
new(
|
|
47
|
+
name: File.basename(root),
|
|
48
|
+
full_name: full_name_from_url(origin),
|
|
49
|
+
default_branch: has_git ? Git.current_branch(root) : nil,
|
|
50
|
+
primary_language: LanguageInference.call(root),
|
|
51
|
+
fork: has_git && Git.upstream_remote?(root),
|
|
52
|
+
archived: false,
|
|
53
|
+
last_activity: has_git ? Git.last_commit_at(root) : nil,
|
|
54
|
+
commit_count: has_git ? Git.commit_count(root) : nil,
|
|
55
|
+
topics: [],
|
|
56
|
+
has_git: has_git
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Parses "owner/repo" from a git remote URL in either SSH or HTTPS form.
|
|
61
|
+
def self.full_name_from_url(url)
|
|
62
|
+
return nil unless url
|
|
63
|
+
cleaned = url.sub(/\.git\z/, "")
|
|
64
|
+
case cleaned
|
|
65
|
+
when %r{[:/]([^/:]+)/([^/]+)\z}
|
|
66
|
+
"#{$1}/#{$2}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_h
|
|
71
|
+
{
|
|
72
|
+
name: name, full_name: full_name, default_branch: default_branch,
|
|
73
|
+
primary_language: primary_language, fork: fork?, archived: archived?,
|
|
74
|
+
last_activity: last_activity&.iso8601, commit_count: commit_count, topics: topics
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# The proof-of-life states a repository can hold (specification section 15).
|
|
5
|
+
# These make "healthy silence" visible: Alive is an earned, evidence-backed
|
|
6
|
+
# status, not the absence of alarms, and Unproven is never allowed to masquerade
|
|
7
|
+
# as green.
|
|
8
|
+
class RepositoryState
|
|
9
|
+
attr_reader :key, :meaning, :color
|
|
10
|
+
|
|
11
|
+
def initialize(key:, meaning:, color:)
|
|
12
|
+
@key = key
|
|
13
|
+
@meaning = meaning
|
|
14
|
+
@color = color
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_sym = key
|
|
19
|
+
|
|
20
|
+
def to_s = key.to_s
|
|
21
|
+
|
|
22
|
+
# @return [Boolean] whether this state counts as verified vitality
|
|
23
|
+
def alive? = key == :alive
|
|
24
|
+
|
|
25
|
+
ALL = {
|
|
26
|
+
alive: new(key: :alive, color: :green,
|
|
27
|
+
meaning: "All due obligations have fresh passing evidence and no unapproved blocking risk."),
|
|
28
|
+
at_risk: new(key: :at_risk, color: :yellow,
|
|
29
|
+
meaning: "A deadline, support window, vulnerability, or degrading signal requires attention soon."),
|
|
30
|
+
needs_you: new(key: :needs_you, color: :magenta,
|
|
31
|
+
meaning: "A decision, permission, secret, environment, or domain judgment blocks safe progress."),
|
|
32
|
+
recovering: new(key: :recovering, color: :cyan,
|
|
33
|
+
meaning: "A repair or coordinated migration is active."),
|
|
34
|
+
unproven: new(key: :unproven, color: :yellow,
|
|
35
|
+
meaning: "A claim exists but sufficient executable evidence is absent or stale."),
|
|
36
|
+
sleeping: new(key: :sleeping, color: :blue,
|
|
37
|
+
meaning: "The repository is intentionally monitored or preserved rather than actively maintained."),
|
|
38
|
+
diverged: new(key: :diverged, color: :magenta,
|
|
39
|
+
meaning: "A maintained fork has meaningful upstream drift or unique commits requiring review."),
|
|
40
|
+
retired: new(key: :retired, color: :white,
|
|
41
|
+
meaning: "End-of-life is explicit and its retirement checklist is complete.")
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
def self.fetch(key)
|
|
45
|
+
ALL.fetch(key.to_sym) { raise UnknownError, "Unknown repository state #{key.inspect}." }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Routing
|
|
5
|
+
# The outcome of routing one task to one profile (specification section 08).
|
|
6
|
+
# A decision is never silent: it carries the human-readable explanation the
|
|
7
|
+
# spec insists on ("Selected Codex because ... Amp was excluded because
|
|
8
|
+
# ...") and the declared fallbacks - fallback is never implied.
|
|
9
|
+
class Decision
|
|
10
|
+
attr_reader :profile, :strategy, :explanation, :excluded, :fallbacks
|
|
11
|
+
|
|
12
|
+
def initialize(profile:, strategy:, explanation:, excluded: [], fallbacks: [])
|
|
13
|
+
@profile = profile
|
|
14
|
+
@strategy = strategy
|
|
15
|
+
@explanation = explanation
|
|
16
|
+
@excluded = excluded
|
|
17
|
+
@fallbacks = fallbacks
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def selected? = !profile.nil?
|
|
21
|
+
|
|
22
|
+
def provider = profile&.provider
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
strategy: strategy,
|
|
27
|
+
selected: provider,
|
|
28
|
+
explanation: explanation,
|
|
29
|
+
fallbacks: fallbacks.map(&:provider),
|
|
30
|
+
excluded: excluded
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Routing
|
|
5
|
+
# Chooses which ready profile performs a task (specification section 08
|
|
6
|
+
# routing strategies). Provider choice is an execution concern, not the
|
|
7
|
+
# repository's definition of life, so routing operates entirely on
|
|
8
|
+
# commissioned profiles and always produces a visible explanation. Fallback
|
|
9
|
+
# is considered only when explicitly declared, and only across profiles that
|
|
10
|
+
# share a data-handling policy.
|
|
11
|
+
class Router
|
|
12
|
+
STRATEGIES = %i[auto pinned ordered task_routed local_only manual].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(profiles, strategy: :auto, order: [], pin: nil, fallback: [], task_routes: {})
|
|
15
|
+
@profiles = profiles
|
|
16
|
+
@strategy = strategy.to_sym
|
|
17
|
+
@order = Array(order).map(&:to_sym)
|
|
18
|
+
@pin = pin&.to_sym
|
|
19
|
+
@fallback = Array(fallback)
|
|
20
|
+
@task_routes = task_routes
|
|
21
|
+
raise UnknownError, "Unknown routing strategy #{strategy.inspect}." unless STRATEGIES.include?(@strategy)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param required_capabilities [Array<Symbol>]
|
|
25
|
+
# @param task_class [Symbol, nil]
|
|
26
|
+
# @return [Decision]
|
|
27
|
+
def select(required_capabilities: [], task_class: nil)
|
|
28
|
+
excluded = []
|
|
29
|
+
eligible = @profiles.select do |profile|
|
|
30
|
+
reason = ineligibility(profile, required_capabilities, task_class)
|
|
31
|
+
excluded << {provider: profile.provider, reason: reason} if reason
|
|
32
|
+
reason.nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
chosen = choose(eligible, task_class)
|
|
36
|
+
fallbacks = chosen ? declared_fallbacks(chosen) : []
|
|
37
|
+
|
|
38
|
+
Decision.new(
|
|
39
|
+
profile: chosen, strategy: @strategy, excluded: excluded, fallbacks: fallbacks,
|
|
40
|
+
explanation: explain(chosen, excluded, fallbacks, required_capabilities, task_class)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Returns a reason string when a profile cannot be used, else nil.
|
|
47
|
+
def ineligibility(profile, required_capabilities, task_class)
|
|
48
|
+
return "it is not commissioned ready (#{profile.readiness})" unless profile.ready?
|
|
49
|
+
|
|
50
|
+
missing = required_capabilities.map(&:to_sym) - profile.capabilities
|
|
51
|
+
return "it lacks required capabilities: #{missing.join(", ")}" if missing.any?
|
|
52
|
+
|
|
53
|
+
case @strategy
|
|
54
|
+
when :local_only
|
|
55
|
+
"it is not a local profile" unless profile.connection.local?
|
|
56
|
+
when :pinned
|
|
57
|
+
"it is not the pinned provider (#{@pin})" unless profile.provider == @pin
|
|
58
|
+
when :task_routed
|
|
59
|
+
expected = @task_routes[task_class]
|
|
60
|
+
"task #{task_class} routes to #{expected}, not #{profile.provider}" if expected && profile.provider != expected
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def choose(eligible, task_class)
|
|
65
|
+
return nil if @strategy == :manual
|
|
66
|
+
|
|
67
|
+
case @strategy
|
|
68
|
+
when :ordered
|
|
69
|
+
@order.filter_map { |provider| eligible.find { |p| p.provider == provider } }.first
|
|
70
|
+
when :pinned
|
|
71
|
+
eligible.find { |p| p.provider == @pin }
|
|
72
|
+
when :task_routed
|
|
73
|
+
eligible.find { |p| p.provider == @task_routes[task_class] } || prefer(eligible)
|
|
74
|
+
else # auto, local_only
|
|
75
|
+
prefer(eligible)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Prefer local (cheapest, most private), then declared order, else stable.
|
|
80
|
+
def prefer(eligible)
|
|
81
|
+
eligible.min_by.with_index do |profile, index|
|
|
82
|
+
[profile.connection.local? ? 0 : 1, order_rank(profile), index]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def order_rank(profile)
|
|
87
|
+
rank = @order.index(profile.provider)
|
|
88
|
+
rank || @order.size
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Fallback is never implied: only explicitly declared profiles that are
|
|
92
|
+
# ready and share the chosen profile's data-handling policy qualify.
|
|
93
|
+
def declared_fallbacks(chosen)
|
|
94
|
+
@fallback.select { |f| f.ready? && f != chosen && f.same_data_handling?(chosen) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def explain(chosen, excluded, fallbacks, required_capabilities, task_class)
|
|
98
|
+
parts = []
|
|
99
|
+
if chosen
|
|
100
|
+
because = ["it is commissioned ready"]
|
|
101
|
+
because << "offers #{required_capabilities.join(", ")}" if required_capabilities.any?
|
|
102
|
+
parts << "Selected #{chosen.provider} (#{@strategy}) because #{because.join(" and ")}."
|
|
103
|
+
parts << "Task class: #{task_class}." if task_class
|
|
104
|
+
parts << if fallbacks.any?
|
|
105
|
+
"#{fallbacks.map(&:provider).join(", ")} configured as fallback (same data-handling policy)."
|
|
106
|
+
else
|
|
107
|
+
"No fallback is configured; fallback is never implied."
|
|
108
|
+
end
|
|
109
|
+
else
|
|
110
|
+
parts << if @strategy == :manual
|
|
111
|
+
"No agent was auto-selected: manual routing defers the choice to you."
|
|
112
|
+
else
|
|
113
|
+
"No eligible agent profile was found."
|
|
114
|
+
end
|
|
115
|
+
parts << "Observation and baseline remain automatic."
|
|
116
|
+
end
|
|
117
|
+
excluded.each { |e| parts << "#{e[:provider]} was excluded because #{e[:reason]}." }
|
|
118
|
+
parts.join(" ")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Reanimator
|
|
6
|
+
# An immutable record of one stewardship attempt (specification section 17 run
|
|
7
|
+
# ledger). A run captures its trigger, objective, policy version, agent, the
|
|
8
|
+
# access it used, its evidence, and its outcome - the auditable story of what
|
|
9
|
+
# Reanimator did and why. Outcomes are drawn from the section 17 vocabulary:
|
|
10
|
+
# a healthy no-op, a report, a draft PR, an escalation, or a safe stop.
|
|
11
|
+
class Run
|
|
12
|
+
OUTCOMES = %i[no_op report draft_pr escalation safe_stop].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :outcome, :repository, :policy_version, :trigger, :objective,
|
|
15
|
+
:provider, :access, :pre_state, :post_state, :cost_usd, :proposed_change, :reason, :generated_at
|
|
16
|
+
|
|
17
|
+
def initialize(outcome:, repository:, policy_version:, trigger:, objective: nil, provider: nil,
|
|
18
|
+
access: {}, pre_state: nil, post_state: nil, cost_usd: 0.0, proposed_change: nil, reason: nil, now: nil)
|
|
19
|
+
@outcome = outcome.to_sym
|
|
20
|
+
@repository = repository
|
|
21
|
+
@policy_version = policy_version
|
|
22
|
+
@trigger = trigger
|
|
23
|
+
@objective = objective
|
|
24
|
+
@provider = provider
|
|
25
|
+
@access = access
|
|
26
|
+
@pre_state = pre_state
|
|
27
|
+
@post_state = post_state
|
|
28
|
+
@cost_usd = cost_usd
|
|
29
|
+
@proposed_change = proposed_change
|
|
30
|
+
@reason = reason
|
|
31
|
+
@generated_at = now || Time.now
|
|
32
|
+
raise UnknownError, "Unknown run outcome #{outcome.inspect}." unless OUTCOMES.include?(@outcome)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The whole slice's guarantee: repair never writes to the repository. A
|
|
36
|
+
# proposed draft PR is an artifact, not a submission.
|
|
37
|
+
def wrote_to_repository? = false
|
|
38
|
+
|
|
39
|
+
def to_h
|
|
40
|
+
{
|
|
41
|
+
outcome: outcome, repository: repository.full_name, policy_version: policy_version,
|
|
42
|
+
trigger: trigger, objective: objective&.id, provider: provider, access: access,
|
|
43
|
+
pre_state: pre_state, post_state: post_state, cost_usd: cost_usd,
|
|
44
|
+
reason: reason, proposed_change: proposed_change&.to_h, generated_at: generated_at.iso8601
|
|
45
|
+
}.compact
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|