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,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# A timestamped, read-only measurement of a repository's current obligations
|
|
5
|
+
# and pre-existing failures (specification sections 02 and 14). A baseline is
|
|
6
|
+
# the organism measured before any operation: it separates verified claims,
|
|
7
|
+
# failed claims, missing evidence, and skipped boundaries, and it carries the
|
|
8
|
+
# policy version it was measured against so later runs can detect genuine
|
|
9
|
+
# improvement or regression rather than noise.
|
|
10
|
+
class Baseline
|
|
11
|
+
attr_reader :contract, :results, :state, :generated_at, :executed
|
|
12
|
+
|
|
13
|
+
def initialize(contract:, results:, state:, generated_at:, executed:)
|
|
14
|
+
@contract = contract
|
|
15
|
+
@results = results
|
|
16
|
+
@state = state
|
|
17
|
+
@generated_at = generated_at
|
|
18
|
+
@executed = executed
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @return [Boolean] whether checks actually executed, versus were planned
|
|
22
|
+
def executed? = @executed
|
|
23
|
+
|
|
24
|
+
def repository = contract.repository
|
|
25
|
+
|
|
26
|
+
def policy_version = contract.policy_version
|
|
27
|
+
|
|
28
|
+
# Counts of each result status, e.g. {verified: 3, missing_evidence: 1}.
|
|
29
|
+
# @return [Hash{Symbol=>Integer}]
|
|
30
|
+
def tally
|
|
31
|
+
results.each_with_object(Hash.new(0)) { |r, h| h[r.status] += 1 }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def results_with_status(status)
|
|
35
|
+
results.select { |r| r.status == status.to_sym }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Required criteria that failed - the actionable, blocking ones.
|
|
39
|
+
def failing_required
|
|
40
|
+
results.select { |r| r.required? && r.failed? }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Required criteria that are neither verified nor accepted risk - the ones
|
|
44
|
+
# that genuinely still need attention (failed, missing, planned, skipped).
|
|
45
|
+
def unresolved_required
|
|
46
|
+
results.select { |r| r.required? && !r.verified? && !r.accepted_risk? }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Required criteria whose failure has been explicitly waived - accepted
|
|
50
|
+
# risk, kept separate from verified vitality (section 15).
|
|
51
|
+
def accepted_risks
|
|
52
|
+
results.select(&:accepted_risk?)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The first required criterion that failed, or nil - the natural repair
|
|
56
|
+
# target. Callers ask the baseline rather than digging through #results.
|
|
57
|
+
def first_failing_required_criterion
|
|
58
|
+
failing_required.first&.criterion
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The result for a criterion id, or nil.
|
|
62
|
+
def result_for(criterion_id)
|
|
63
|
+
results.find { |r| r.criterion.id == criterion_id }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Ids of required criteria that are verified - the set a later run must not
|
|
67
|
+
# regress from.
|
|
68
|
+
def verified_required_ids
|
|
69
|
+
results.select { |r| r.required? && r.verified? }.map { |r| r.criterion.id }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The required criteria the Verified Vitality Rate is measured over.
|
|
73
|
+
# Accepted risk is deliberately excluded so it is reported separately
|
|
74
|
+
# rather than counted as a failure (section 15).
|
|
75
|
+
def rated_required
|
|
76
|
+
results.select { |r| r.required? && !r.accepted_risk? }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Verified Vitality Rate for active (required) obligations - the headline
|
|
80
|
+
# dashboard metric from section 15, kept separate from accepted risk.
|
|
81
|
+
# @return [Float] 0.0..1.0
|
|
82
|
+
def verified_vitality_rate
|
|
83
|
+
rated = rated_required
|
|
84
|
+
return 1.0 if rated.empty?
|
|
85
|
+
|
|
86
|
+
rated.count(&:verified?).to_f / rated.size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A repository is proven when it is Alive with no failed or unproven
|
|
90
|
+
# required criteria.
|
|
91
|
+
def proven? = state.alive?
|
|
92
|
+
|
|
93
|
+
def to_h
|
|
94
|
+
{
|
|
95
|
+
repository: repository.full_name,
|
|
96
|
+
state: state.to_sym,
|
|
97
|
+
executed: executed?,
|
|
98
|
+
generated_at: generated_at.iso8601,
|
|
99
|
+
policy_version: policy_version,
|
|
100
|
+
verified_vitality_rate: verified_vitality_rate.round(3),
|
|
101
|
+
tally: tally,
|
|
102
|
+
results: results.map(&:to_h)
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Reanimator
|
|
6
|
+
# Executes a baseline for one repository against its effective contract
|
|
7
|
+
# (specification section 14). Every criterion is graded by an isolated
|
|
8
|
+
# evaluator; the runner itself never edits the repository. Execution is
|
|
9
|
+
# opt-in and doubly gated - by the per-run `execute` flag and by the global
|
|
10
|
+
# `allow_execution` configuration - so the default really is read-only, and
|
|
11
|
+
# even when executing, native (inspection-only) evaluators still run.
|
|
12
|
+
#
|
|
13
|
+
# When commands do execute, they run in a *disposable copy* of the repository,
|
|
14
|
+
# never the original working tree (section 14: "all commands run in a
|
|
15
|
+
# disposable workspace" and "the baseline is read-only with respect to
|
|
16
|
+
# repository source"). Inspection-only evaluators still read the original.
|
|
17
|
+
class BaselineRunner
|
|
18
|
+
attr_reader :contract, :execute, :timeout
|
|
19
|
+
|
|
20
|
+
def initialize(contract, execute: false, timeout: nil, now: nil)
|
|
21
|
+
@contract = contract
|
|
22
|
+
@execute = execute
|
|
23
|
+
@timeout = timeout || Reanimator.configuration.command_timeout
|
|
24
|
+
@now = now
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Baseline]
|
|
28
|
+
def run
|
|
29
|
+
if execution_allowed?
|
|
30
|
+
# Commands run inside a throwaway copy so builds/tests cannot mutate the
|
|
31
|
+
# real checkout (no stray .gem, coverage/, or tmp/ artifacts).
|
|
32
|
+
Isolation.copy(contract.repository.path) { |workspace| measure(workspace) }
|
|
33
|
+
else
|
|
34
|
+
measure(contract.repository.path)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def measure(execution_root)
|
|
41
|
+
results = contract.criteria.map do |criterion|
|
|
42
|
+
Evaluators.for(
|
|
43
|
+
criterion, contract: contract, execute: execution_allowed?,
|
|
44
|
+
timeout: timeout, execution_root: execution_root
|
|
45
|
+
).call
|
|
46
|
+
end
|
|
47
|
+
results = apply_waivers(results)
|
|
48
|
+
|
|
49
|
+
Baseline.new(
|
|
50
|
+
contract: contract,
|
|
51
|
+
results: results,
|
|
52
|
+
state: ProofOfLife.assess(contract, results),
|
|
53
|
+
generated_at: now,
|
|
54
|
+
executed: execution_allowed?
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Re-tags an unresolved required criterion as accepted risk when an active
|
|
59
|
+
# waiver covers it (section 09). The waiver does not make it verified - it
|
|
60
|
+
# records the failure as explicitly accepted, so it stops blocking the
|
|
61
|
+
# repository state while staying visible.
|
|
62
|
+
def apply_waivers(results)
|
|
63
|
+
results.map do |result|
|
|
64
|
+
next result unless result.waivable?
|
|
65
|
+
|
|
66
|
+
waiver = contract.waiver_for(result.criterion.id)
|
|
67
|
+
waiver ? result.accepted_via(waiver) : result
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Execution requires both the per-run request and the global allowance.
|
|
72
|
+
# Native evaluators ignore this flag and always inspect.
|
|
73
|
+
def execution_allowed?
|
|
74
|
+
execute && Reanimator.configuration.allow_execution
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def now
|
|
78
|
+
@now || Time.now
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Reanimator
|
|
7
|
+
# Runs a single shell command under a hard wall-clock bound and captures its
|
|
8
|
+
# combined output (specification section 12: bounded attempts, time, and
|
|
9
|
+
# paths). Baselines are bounded by construction - a check that hangs is a
|
|
10
|
+
# failed check, never an unbounded wait. The command runs through `sh -c` so
|
|
11
|
+
# discovered commands with globs (for example `gem build *.gemspec`) behave as
|
|
12
|
+
# they do in CI.
|
|
13
|
+
#
|
|
14
|
+
# The child is started as its own process-group leader and, on timeout, the
|
|
15
|
+
# *entire group* is signalled - not just the `sh` parent - so a spawned
|
|
16
|
+
# grandchild (the actual test or build process) cannot outlive the bound.
|
|
17
|
+
module BoundedCommand
|
|
18
|
+
# Cap on captured output so a runaway producer cannot exhaust memory before
|
|
19
|
+
# the time bound fires. The pipe is still drained past the cap so the child
|
|
20
|
+
# never blocks on a full pipe; only accumulation stops.
|
|
21
|
+
MAX_OUTPUT_BYTES = 1 << 20 # 1 MiB
|
|
22
|
+
READ_CHUNK = 16_384
|
|
23
|
+
|
|
24
|
+
Result = Struct.new(:success, :output, :duration, :timed_out) do
|
|
25
|
+
def success? = success
|
|
26
|
+
def timed_out? = timed_out
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# @param command [String]
|
|
32
|
+
# @param chdir [String]
|
|
33
|
+
# @param timeout [Integer] seconds
|
|
34
|
+
# @return [Result]
|
|
35
|
+
def run(command, chdir:, timeout:)
|
|
36
|
+
started = clock
|
|
37
|
+
stdin, out_and_err, wait_thread = Open3.popen2e("sh", "-c", command, chdir: chdir, pgroup: true)
|
|
38
|
+
stdin.close
|
|
39
|
+
out_and_err.binmode
|
|
40
|
+
|
|
41
|
+
output = +"".b
|
|
42
|
+
truncated = false
|
|
43
|
+
timed_out = false
|
|
44
|
+
begin
|
|
45
|
+
Timeout.timeout(timeout) do
|
|
46
|
+
while (chunk = out_and_err.read(READ_CHUNK))
|
|
47
|
+
if output.bytesize < MAX_OUTPUT_BYTES
|
|
48
|
+
output << chunk
|
|
49
|
+
else
|
|
50
|
+
truncated = true # keep draining, stop accumulating
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
wait_thread.join
|
|
55
|
+
rescue Timeout::Error
|
|
56
|
+
timed_out = true
|
|
57
|
+
kill_group(wait_thread.pid)
|
|
58
|
+
wait_thread.join # reap the terminated leader
|
|
59
|
+
ensure
|
|
60
|
+
out_and_err.close
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
output << "\n...[output truncated]" if truncated
|
|
64
|
+
|
|
65
|
+
Result.new(
|
|
66
|
+
success: !timed_out && wait_thread.value&.success?,
|
|
67
|
+
# Command output is untrusted bytes; present it as scrubbed UTF-8 so
|
|
68
|
+
# callers' string operations never raise.
|
|
69
|
+
output: output.force_encoding("UTF-8").scrub,
|
|
70
|
+
duration: (clock - started).round(2),
|
|
71
|
+
timed_out: timed_out
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Signals the whole process group led by pid (the negative-pid form), so
|
|
76
|
+
# every process the command spawned is terminated together.
|
|
77
|
+
def kill_group(pid)
|
|
78
|
+
Process.kill("TERM", -pid)
|
|
79
|
+
Process.kill("KILL", -pid)
|
|
80
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
81
|
+
# Already gone; nothing to clean up.
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Monotonic clock so a system time change cannot skew a duration.
|
|
85
|
+
def clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# Assigns a repository to a triage cohort and a provisional lifecycle intent
|
|
5
|
+
# from read-only evidence (specification sections 04 and 05). The classifier
|
|
6
|
+
# is deliberately conservative: it flags ambiguity rather than assuming
|
|
7
|
+
# maintenance, and it never treats its own suggestion as final - intent must
|
|
8
|
+
# be confirmed before activation.
|
|
9
|
+
class Classifier
|
|
10
|
+
# Gems that imply a hardware- or credential-constrained environment.
|
|
11
|
+
HARDWARE_MARKERS = %w[rpi_gpio pi_piper serialport dino artoo denko wiringpi].freeze
|
|
12
|
+
RECENT_DAYS = 120
|
|
13
|
+
STALE_DAYS = 365
|
|
14
|
+
EXPERIMENT_COMMITS = 15
|
|
15
|
+
|
|
16
|
+
# The result of classification: the cohort, the intent it suggests, how
|
|
17
|
+
# confident that suggestion is, and why.
|
|
18
|
+
Classification = Struct.new(:cohort, :suggested_intent, :confidence, :reason) do
|
|
19
|
+
def recommended_for_onboarding? = cohort.recommended_for_onboarding?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :repository, :discovery
|
|
23
|
+
|
|
24
|
+
def initialize(repository, discovery: nil)
|
|
25
|
+
@repository = repository
|
|
26
|
+
@discovery = discovery
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Classification]
|
|
30
|
+
def classify
|
|
31
|
+
cohort_key, confidence, reason = decide
|
|
32
|
+
cohort = Cohort.fetch(cohort_key)
|
|
33
|
+
Classification.new(
|
|
34
|
+
cohort: cohort,
|
|
35
|
+
suggested_intent: cohort.default_intent,
|
|
36
|
+
confidence: Confidence[confidence],
|
|
37
|
+
reason: reason
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def decide
|
|
44
|
+
metadata = repository.metadata
|
|
45
|
+
|
|
46
|
+
if repository.fork? || discovery&.fact?(:fork_of)
|
|
47
|
+
[:fork_observation, :high, "The repository is a fork; defaulting to upstream observation."]
|
|
48
|
+
elsif hardware?
|
|
49
|
+
[:constrained, :medium, "Hardware/credential-dependent gems detected; a constrained profile is safer."]
|
|
50
|
+
elsif metadata.archived?
|
|
51
|
+
[:archived, :high, "The repository is archived."]
|
|
52
|
+
elsif readme_only?
|
|
53
|
+
[:readme_only, :medium, "No recognized source language; looks like a concept or presentation."]
|
|
54
|
+
elsif stale?
|
|
55
|
+
[:old_unarchived, :medium, "No activity for over a year; intent is ambiguous rather than clearly maintained."]
|
|
56
|
+
elsif experiment?
|
|
57
|
+
[:recent_experiment, :medium, "Recent, low-commit repository; likely still an experiment."]
|
|
58
|
+
else
|
|
59
|
+
[:active_original, :high, "Active original repository with recognizable structure."]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def hardware?
|
|
64
|
+
body = repository.read("Gemfile").to_s + repository.read("Gemfile.lock").to_s
|
|
65
|
+
HARDWARE_MARKERS.any? { |marker| body.include?(marker) } ||
|
|
66
|
+
HARDWARE_MARKERS.any? { |marker| repository.name.include?(marker) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def readme_only?
|
|
70
|
+
repository.metadata.primary_language.nil? &&
|
|
71
|
+
!repository.any?("*.gemspec") &&
|
|
72
|
+
!repository.exist?("package.json") &&
|
|
73
|
+
repository.any?("README*")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stale?
|
|
77
|
+
days = repository.metadata.days_since_activity
|
|
78
|
+
days && days > STALE_DAYS
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def experiment?
|
|
82
|
+
days = repository.metadata.days_since_activity
|
|
83
|
+
count = repository.metadata.commit_count
|
|
84
|
+
return false unless days && count
|
|
85
|
+
|
|
86
|
+
days <= RECENT_DAYS && count < EXPERIMENT_COMMITS
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Reanimator
|
|
7
|
+
# The interactive surface. Every command here is read-only by default and
|
|
8
|
+
# maps to one onboarding stage from section 03: inventory (classify),
|
|
9
|
+
# discover, contract (review), and baseline (measure). Nothing in this CLI
|
|
10
|
+
# writes to a repository or connects an agent - it exists to make the
|
|
11
|
+
# repository's definition of life visible, executable, and trustworthy.
|
|
12
|
+
class CLI < Thor
|
|
13
|
+
def self.exit_on_failure? = true
|
|
14
|
+
|
|
15
|
+
desc "version", "Print the Reanimator version"
|
|
16
|
+
def version
|
|
17
|
+
puts Reanimator::VERSION
|
|
18
|
+
end
|
|
19
|
+
map %w[--version -v] => :version
|
|
20
|
+
|
|
21
|
+
desc "inventory [PATH...]", "Classify repositories into lifecycle cohorts (read-only)"
|
|
22
|
+
long_desc <<~DESC
|
|
23
|
+
Stage 1 of onboarding: classify before acting. Groups each repository into
|
|
24
|
+
a suggested cohort and lifecycle intent, and recommends a small,
|
|
25
|
+
high-confidence set for onboarding - never all of them.
|
|
26
|
+
DESC
|
|
27
|
+
def inventory(*paths)
|
|
28
|
+
guard do
|
|
29
|
+
paths = [Dir.pwd] if paths.empty?
|
|
30
|
+
puts Reports::Inventory.new(Reanimator.inventory(paths)).render
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc "discover [PATH]", "Show discovered facts and their provenance (read-only)"
|
|
35
|
+
def discover(path = Dir.pwd)
|
|
36
|
+
guard { puts Reports::Discovery.new(Reanimator.discover(path)).render }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "contract [PATH]", "Show or export the effective vitality contract"
|
|
40
|
+
option :intent, desc: "Override the inferred lifecycle intent (e.g. maintain, preserve)"
|
|
41
|
+
option :export, type: :boolean, default: false, desc: "Print a minimal .reanimator.yml instead of the review"
|
|
42
|
+
def contract(path = Dir.pwd)
|
|
43
|
+
guard do
|
|
44
|
+
contract = Reanimator.contract(path, intent: options[:intent]&.to_sym)
|
|
45
|
+
if options[:export]
|
|
46
|
+
puts ContractFile.dump(contract.to_minimal_hash)
|
|
47
|
+
else
|
|
48
|
+
puts Reports::Contract.new(contract).render
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
desc "baseline [PATH]", "Measure a read-only proof-of-life baseline"
|
|
54
|
+
long_desc <<~DESC
|
|
55
|
+
Runs the effective contract's acceptance criteria and reports a
|
|
56
|
+
proof-of-life state. Native checks (integrity, documentation, support
|
|
57
|
+
matrix) always run; command and boot checks only plan unless --execute is
|
|
58
|
+
given, and even then execution is bounded and never edits source.
|
|
59
|
+
DESC
|
|
60
|
+
option :execute, type: :boolean, default: false, desc: "Actually run discovered checks (bounded, read-only)"
|
|
61
|
+
option :intent, desc: "Override the inferred lifecycle intent"
|
|
62
|
+
option :format, default: "text", enum: %w[text check json], desc: "Output format"
|
|
63
|
+
def baseline(path = Dir.pwd)
|
|
64
|
+
guard do
|
|
65
|
+
baseline = Reanimator.baseline(path, execute: options[:execute], intent: options[:intent]&.to_sym)
|
|
66
|
+
puts render_baseline(baseline, options[:format])
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
desc "portfolio [PATH...]", "Baseline several repositories into one report (Appendix A)"
|
|
71
|
+
option :execute, type: :boolean, default: false, desc: "Actually run discovered checks (bounded, read-only)"
|
|
72
|
+
option :name, default: "portfolio", desc: "Portfolio name shown in the report header"
|
|
73
|
+
def portfolio(*paths)
|
|
74
|
+
guard do
|
|
75
|
+
paths = [Dir.pwd] if paths.empty?
|
|
76
|
+
baselines = paths.filter_map do |path|
|
|
77
|
+
Reanimator.baseline(path, execute: options[:execute])
|
|
78
|
+
rescue RepositoryError => e
|
|
79
|
+
warn "skipping #{path}: #{e.message}"
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
puts Reports::PortfolioBaseline.new(baselines, name: options[:name]).render
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
desc "agents", "List coding-agent providers and whether each is available"
|
|
87
|
+
def agents
|
|
88
|
+
guard { puts Reports::Agents.new(Reanimator.adapters).render }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
desc "commission", "Prove a coding agent is ready via a synthetic repair (no repository writes)"
|
|
92
|
+
long_desc <<~DESC
|
|
93
|
+
Stage 5 of onboarding. Runs the commissioning test from section 08: a
|
|
94
|
+
synthetic repair against a disposable fixture, graded independently by
|
|
95
|
+
Reanimator's own evaluators. The default local agent always runs; hosted
|
|
96
|
+
providers report themselves unavailable until connected.
|
|
97
|
+
DESC
|
|
98
|
+
option :provider, default: "local", desc: "Provider to commission (local, codex, claude, amp)"
|
|
99
|
+
def commission
|
|
100
|
+
guard do
|
|
101
|
+
report = Reanimator.commission(provider: options[:provider].to_sym)
|
|
102
|
+
puts Reports::Commissioning.new(report).render
|
|
103
|
+
exit 1 unless report.ready? || report.readiness == :unavailable
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
desc "repair [PATH]", "Attempt a bounded repair and propose a draft PR (no repository writes)"
|
|
108
|
+
long_desc <<~DESC
|
|
109
|
+
Stage toward bounded stewardship (section 11 repair rung). Finds the first
|
|
110
|
+
failing required criterion, checks the permission model, routes to a ready
|
|
111
|
+
agent, and lets it propose a patch in an isolated workspace. The patch is
|
|
112
|
+
applied only to a separate clean copy and re-verified independently; on
|
|
113
|
+
success a draft PR is materialized but NOT submitted. Nothing is written to
|
|
114
|
+
the repository.
|
|
115
|
+
DESC
|
|
116
|
+
option :provider, default: "local", desc: "Provider to use (local, codex, claude, amp)"
|
|
117
|
+
option :task_class, default: "patch_dependency", desc: "Task class governing autonomy"
|
|
118
|
+
def repair(path = Dir.pwd)
|
|
119
|
+
guard do
|
|
120
|
+
run = Reanimator.repair(path, provider: options[:provider].to_sym, task_class: options[:task_class].to_sym)
|
|
121
|
+
puts Reports::Run.new(run).render
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
desc "drift [PATH...]", "Record contract snapshots, or detect drift against them"
|
|
126
|
+
long_desc <<~DESC
|
|
127
|
+
Section 06. Configuration drift is the difference between the contract a
|
|
128
|
+
repository resolved to when it was last recorded and the one its evidence
|
|
129
|
+
resolves to now. Differences are separated into policy changes - less is
|
|
130
|
+
being proven than before, so a human should look - and benign ones, such
|
|
131
|
+
as a renamed test command. The exit status is non-zero only for a policy
|
|
132
|
+
change, so CI can gate on it.
|
|
133
|
+
|
|
134
|
+
A repository's snapshot normally lives in the repository, committed as
|
|
135
|
+
.reanimator-snapshot.json, so a policy change arrives as a diff in a
|
|
136
|
+
pull request rather than as state hidden in a tool; --record writes it
|
|
137
|
+
there. For repositories you cannot commit a file into, --snapshots DIR
|
|
138
|
+
keeps them side by side, keyed by repository. Pass several paths for a
|
|
139
|
+
portfolio triage list. With nothing recorded yet, shows what the
|
|
140
|
+
repository is currently required to prove. The only files Reanimator
|
|
141
|
+
ever writes are those snapshots, and only when asked.
|
|
142
|
+
DESC
|
|
143
|
+
option :since, desc: "Path to a recorded snapshot to compare against (one repository)"
|
|
144
|
+
option :save, desc: "Path to write the current snapshot to (one repository)"
|
|
145
|
+
option :snapshots, desc: "Directory of recorded snapshots, keyed by repository"
|
|
146
|
+
option :record, type: :boolean, default: false, desc: "Record each current snapshot where it belongs"
|
|
147
|
+
option :intent, desc: "Override the inferred lifecycle intent"
|
|
148
|
+
option :format, default: "text", enum: %w[text json], desc: "Output format"
|
|
149
|
+
def drift(*paths)
|
|
150
|
+
guard do
|
|
151
|
+
paths = [Dir.pwd] if paths.empty?
|
|
152
|
+
(paths.size > 1) ? portfolio_drift(paths) : single_drift(paths.first)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
desc "permissions [PATH]", "Show resolved autonomy per task class for a repository"
|
|
157
|
+
option :intent, desc: "Override the inferred lifecycle intent"
|
|
158
|
+
def permissions(path = Dir.pwd)
|
|
159
|
+
guard do
|
|
160
|
+
contract = Reanimator.contract(path, intent: options[:intent]&.to_sym)
|
|
161
|
+
puts Reports::Permissions.new(contract).render
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
desc "families [PATH...]", "Show the compatibility graph: repositories that must stay alive together"
|
|
166
|
+
long_desc <<~DESC
|
|
167
|
+
Section 10. Groups related repositories into families from their structure -
|
|
168
|
+
a project and its examples, deployment samples, docs, or plugins - and shows
|
|
169
|
+
the typed edge between each pair plus the gate that proves it.
|
|
170
|
+
DESC
|
|
171
|
+
def families(*paths)
|
|
172
|
+
guard do
|
|
173
|
+
paths = [Dir.pwd] if paths.empty?
|
|
174
|
+
puts Reports::Families.new(Reanimator.families(paths)).render
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
desc "gates [PATH...]", "Evaluate cross-repository family gates (read-only)"
|
|
179
|
+
long_desc <<~DESC
|
|
180
|
+
Proves each family edge by running the downstream member's own check. A
|
|
181
|
+
candidate change upstream can trigger these downstream checks without
|
|
182
|
+
opening anything downstream: with --execute each check runs bounded, in a
|
|
183
|
+
disposable copy, never in the downstream working tree. A failure names the
|
|
184
|
+
exact edge that broke.
|
|
185
|
+
DESC
|
|
186
|
+
option :family, desc: "Limit to one family id"
|
|
187
|
+
option :execute, type: :boolean, default: false, desc: "Actually run downstream checks (bounded, read-only)"
|
|
188
|
+
def gates(*paths)
|
|
189
|
+
guard do
|
|
190
|
+
paths = [Dir.pwd] if paths.empty?
|
|
191
|
+
evaluated = Reanimator.family_gates(paths, family: options[:family], execute: options[:execute])
|
|
192
|
+
if evaluated.empty?
|
|
193
|
+
puts "No repository families detected."
|
|
194
|
+
else
|
|
195
|
+
puts evaluated.map { |family, results| Reports::FamilyGates.new(family, results).render }.join("\n\n")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private
|
|
201
|
+
|
|
202
|
+
# Several paths: a triage list across the portfolio, not one verdict.
|
|
203
|
+
def portfolio_drift(paths)
|
|
204
|
+
%w[since save].each do |single|
|
|
205
|
+
next unless options[single]
|
|
206
|
+
|
|
207
|
+
raise ContractError,
|
|
208
|
+
"--#{single} names one file, so it cannot be used with several repositories; use --snapshots DIR."
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
drift = Reanimator.portfolio_drift(
|
|
212
|
+
paths, snapshots: options[:snapshots], record: options[:record], intent: options[:intent]&.to_sym
|
|
213
|
+
)
|
|
214
|
+
puts((options[:format] == "json") ? JSON.pretty_generate(drift.to_h) : Reports::PortfolioDrift.new(drift).render)
|
|
215
|
+
exit 1 if drift.requires_review?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def single_drift(path)
|
|
219
|
+
store = SnapshotStore.new(directory: options[:snapshots])
|
|
220
|
+
repository = Repository.at(path)
|
|
221
|
+
snapshot = Reanimator.snapshot(path, intent: options[:intent]&.to_sym)
|
|
222
|
+
previous = options[:since] ? ContractSnapshot.load(options[:since]) : store.load(repository)
|
|
223
|
+
report = previous && DriftDetector.between(previous, snapshot)
|
|
224
|
+
|
|
225
|
+
recorded_to = record_snapshot(snapshot, repository, store)
|
|
226
|
+
puts render_drift(report, snapshot, recorded_to, store)
|
|
227
|
+
exit 1 if report&.requires_review?
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A snapshot is written only where the maintainer asked for it: --save names
|
|
231
|
+
# an exact file, --record puts it where the store says it belongs. Nothing
|
|
232
|
+
# is written without one of them - detecting drift is not licence to start
|
|
233
|
+
# writing files.
|
|
234
|
+
def record_snapshot(snapshot, repository, store)
|
|
235
|
+
return unless options[:save] || options[:record]
|
|
236
|
+
|
|
237
|
+
written = options[:save] ? save_snapshot(snapshot, options[:save]) : store.save(snapshot, repository)
|
|
238
|
+
warn "Recorded contract snapshot for #{snapshot.repository} → #{written}"
|
|
239
|
+
written
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def render_drift(report, snapshot, recorded_to, store)
|
|
243
|
+
return JSON.pretty_generate(report ? report.to_h : snapshot.to_h) if options[:format] == "json"
|
|
244
|
+
return Reports::Drift.new(report, record_with: store.record_command(options[:since])).render if report
|
|
245
|
+
|
|
246
|
+
Reports::Snapshot.new(snapshot, saved_to: recorded_to, record_with: store.record_command).render
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def save_snapshot(snapshot, destination)
|
|
250
|
+
File.write(destination, snapshot.to_json)
|
|
251
|
+
destination
|
|
252
|
+
rescue SystemCallError => e
|
|
253
|
+
raise ContractError, "Could not write the contract snapshot to #{destination}: #{e.message}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def render_baseline(baseline, format)
|
|
257
|
+
case format
|
|
258
|
+
when "check" then Reports::GitHubCheck.new(baseline).render
|
|
259
|
+
when "json" then JSON.pretty_generate(baseline.to_h)
|
|
260
|
+
else Reports::Baseline.new(baseline).render
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Turns library errors into a clean, single-line CLI failure instead of a
|
|
265
|
+
# backtrace - the tool is for maintainers, not for reading stack traces.
|
|
266
|
+
def guard
|
|
267
|
+
yield
|
|
268
|
+
rescue Reanimator::Error => e
|
|
269
|
+
warn "reanimator: #{e.message}"
|
|
270
|
+
exit 1
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|