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,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Detects high-value proof-of-life evaluators (specification section 06:
|
|
6
|
+
# Operational proof and Contracts). Health endpoints, doctor commands, and
|
|
7
|
+
# smoke scripts are the cheapest, most trustworthy evidence that software
|
|
8
|
+
# actually runs, so they are called out for promotion into acceptance
|
|
9
|
+
# criteria.
|
|
10
|
+
class OperationalProof < Base
|
|
11
|
+
SMOKE_SCRIPTS = ["bin/smoke", "bin/doctor", "script/smoke", "bin/health"].freeze
|
|
12
|
+
HEALTH_ROUTE = %r{(get\s+['"]/(up|health|healthz)['"]|/up\b)}
|
|
13
|
+
|
|
14
|
+
def detect
|
|
15
|
+
facts = []
|
|
16
|
+
|
|
17
|
+
if (script = SMOKE_SCRIPTS.find { |s| repository.exist?(s) })
|
|
18
|
+
facts << fact(:smoke_script, category: :operational_proof,
|
|
19
|
+
claim: "A smoke/doctor script is available: #{script}.",
|
|
20
|
+
value: script, confidence: :high, source: script, method: :file_present)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if health_endpoint?
|
|
24
|
+
facts << fact(:health_endpoint, category: :operational_proof,
|
|
25
|
+
claim: "Exposes a health endpoint suitable for a boot smoke test.",
|
|
26
|
+
confidence: :medium, source: "routes/config", method: :regex_match)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if repository.exist?("examples") || repository.any?("examples/**/*")
|
|
30
|
+
facts << fact(:examples, category: :contracts,
|
|
31
|
+
claim: "Ships runnable examples that can back behavioral claims.",
|
|
32
|
+
confidence: :medium, source: "examples/", method: :file_present)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
facts
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def health_endpoint?
|
|
41
|
+
candidates = repository.glob("config/routes.rb") +
|
|
42
|
+
repository.glob("config.ru") +
|
|
43
|
+
repository.glob("app.rb")
|
|
44
|
+
candidates.any? { |path| repository.read(path).to_s.match?(HEALTH_ROUTE) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Detects a Ruby on Rails application (specification section 06: Ecosystem
|
|
6
|
+
# and Deployment). The signal is a config/application.rb that boots Rails,
|
|
7
|
+
# optionally corroborated by a `rails` dependency and a bin/rails script.
|
|
8
|
+
# Deployment surfaces (Kamal, Docker, Compose) are recorded so the resolver
|
|
9
|
+
# can flag protected paths.
|
|
10
|
+
class RailsApp < Base
|
|
11
|
+
def detect
|
|
12
|
+
return [] unless rails?
|
|
13
|
+
|
|
14
|
+
facts = [
|
|
15
|
+
fact(:rails_app, category: :ecosystem,
|
|
16
|
+
claim: "A Ruby on Rails application.",
|
|
17
|
+
confidence: :high, source: "config/application.rb", method: :file_present)
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
deployment_facts.each { |f| facts << f }
|
|
21
|
+
facts
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def rails?
|
|
27
|
+
return false unless repository.exist?("config/application.rb")
|
|
28
|
+
|
|
29
|
+
body = repository.read("config/application.rb").to_s
|
|
30
|
+
body.include?("Rails::Application") || repository.exist?("bin/rails")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
DEPLOYMENT_EVIDENCE = {
|
|
34
|
+
"config/deploy.yml" => "Kamal deployment",
|
|
35
|
+
"Dockerfile" => "Docker image build",
|
|
36
|
+
"docker-compose.yml" => "Docker Compose services",
|
|
37
|
+
"compose.yaml" => "Docker Compose services"
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def deployment_facts
|
|
41
|
+
DEPLOYMENT_EVIDENCE.filter_map do |path, description|
|
|
42
|
+
next unless repository.exist?(path)
|
|
43
|
+
|
|
44
|
+
fact(:"deploy_#{File.basename(path, ".*").downcase}", category: :deployment,
|
|
45
|
+
claim: "Deployment surface: #{description}.",
|
|
46
|
+
confidence: :high, source: path, method: :file_present)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Detects cross-repository edges (specification section 06: Relationships,
|
|
6
|
+
# and section 10: repository families). Path dependencies, submodules, and
|
|
7
|
+
# documentation links are the seams where "keep connected projects alive
|
|
8
|
+
# together" begins - each edge is a candidate member of a repository
|
|
9
|
+
# family whose health must be evaluated jointly.
|
|
10
|
+
class Relationships < Base
|
|
11
|
+
GEM_PATH = /gem\s+['"]([^'"]+)['"].*\bpath:\s*['"]([^'"]+)['"]/
|
|
12
|
+
GITHUB_REF = %r{github\.com[:/]([\w.-]+/[\w.-]+?)(?:\.git|/|["')\s]|\z)}
|
|
13
|
+
|
|
14
|
+
def detect
|
|
15
|
+
facts = path_dependency_facts
|
|
16
|
+
facts.concat(submodule_facts)
|
|
17
|
+
facts
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def path_dependency_facts
|
|
23
|
+
gemfile = repository.read("Gemfile")
|
|
24
|
+
return [] unless gemfile
|
|
25
|
+
|
|
26
|
+
gemfile.scan(GEM_PATH).map do |gem_name, local_path|
|
|
27
|
+
fact(:"path_dep_#{gem_name.gsub(/[^a-z0-9]+/i, "_")}", category: :relationships,
|
|
28
|
+
claim: "Depends on a local path gem: #{gem_name} (#{local_path}).",
|
|
29
|
+
value: {gem: gem_name, path: local_path}, confidence: :high,
|
|
30
|
+
source: "Gemfile", method: :manifest_parse, detail: local_path)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def submodule_facts
|
|
35
|
+
body = repository.read(".gitmodules")
|
|
36
|
+
return [] unless body
|
|
37
|
+
|
|
38
|
+
body.scan(GITHUB_REF).flatten.uniq.map do |full_name|
|
|
39
|
+
fact(:"submodule_#{full_name.gsub(%r{[^a-z0-9]+}i, "_")}", category: :relationships,
|
|
40
|
+
claim: "Includes submodule #{full_name}.",
|
|
41
|
+
value: full_name, confidence: :high, source: ".gitmodules", method: :manifest_parse, detail: full_name)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Detects that a repository is packaged as a Ruby gem (specification
|
|
6
|
+
# section 06: Ecosystem). A gemspec is the single strongest signal that the
|
|
7
|
+
# ruby-gem vitality profile applies, so this fact is high-confidence and
|
|
8
|
+
# names the gemspec as its evidence.
|
|
9
|
+
class RubyGem < Base
|
|
10
|
+
def detect
|
|
11
|
+
gemspec = first_path("*.gemspec")
|
|
12
|
+
return [] unless gemspec
|
|
13
|
+
|
|
14
|
+
facts = [
|
|
15
|
+
fact(:ruby_gem, category: :ecosystem,
|
|
16
|
+
claim: "Packaged and released as a Ruby gem.",
|
|
17
|
+
confidence: :high, source: gemspec, method: :file_present)
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
if (exe = executables).any?
|
|
21
|
+
facts << fact(:executables, category: :operational_proof,
|
|
22
|
+
claim: "Ships command-line executable(s): #{exe.join(", ")}.",
|
|
23
|
+
value: exe, confidence: :high, source: exe.first, method: :file_present)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
facts
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Executables shipped in exe/ or bin/ (excluding the developer scripts
|
|
32
|
+
# bundler generates - setup and console are not product surface).
|
|
33
|
+
def executables
|
|
34
|
+
(repository.glob("exe/*") + repository.glob("bin/*"))
|
|
35
|
+
.reject { |p| %w[bin/setup bin/console].include?(p) }
|
|
36
|
+
.select { |p| File.file?(repository.absolute(p)) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
module Detectors
|
|
5
|
+
# Detects repository-level security controls that live outside CI
|
|
6
|
+
# (specification section 06: Security controls). A SECURITY.md, a
|
|
7
|
+
# Dependabot configuration, or a CODEOWNERS file are all existing evidence
|
|
8
|
+
# that should be credited rather than re-requested during onboarding.
|
|
9
|
+
class SecurityControls < Base
|
|
10
|
+
EVIDENCE = {
|
|
11
|
+
security_policy: {
|
|
12
|
+
globs: ["SECURITY.md", ".github/SECURITY.md", "docs/SECURITY.md"],
|
|
13
|
+
claim: "A security policy is published (SECURITY.md)."
|
|
14
|
+
},
|
|
15
|
+
dependabot: {
|
|
16
|
+
globs: [".github/dependabot.yml", ".github/dependabot.yaml"],
|
|
17
|
+
claim: "Automated dependency updates are configured (Dependabot)."
|
|
18
|
+
},
|
|
19
|
+
codeowners: {
|
|
20
|
+
globs: ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"],
|
|
21
|
+
claim: "Ownership is declared (CODEOWNERS)."
|
|
22
|
+
}
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
def detect
|
|
26
|
+
EVIDENCE.filter_map do |key, spec|
|
|
27
|
+
path = spec[:globs].find { |g| repository.exist?(g) }
|
|
28
|
+
next unless path
|
|
29
|
+
|
|
30
|
+
fact(key, category: :security_controls,
|
|
31
|
+
claim: spec[:claim], confidence: :high, source: path, method: :file_present)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# A claim inferred from evidence - "RSpec exists", "Docker image builds in
|
|
5
|
+
# CI", "supported Ruby is 3.1-3.3" - carrying its source, confidence, and
|
|
6
|
+
# human-review status (specification sections 02 and 06). A discovered fact
|
|
7
|
+
# is a *proposal*, not a decision: it becomes declared policy only once a
|
|
8
|
+
# human accepts it.
|
|
9
|
+
class DiscoveredFact
|
|
10
|
+
# Detector categories from section 06. A fact always belongs to exactly one.
|
|
11
|
+
CATEGORIES = %i[
|
|
12
|
+
ecosystem commands support_matrix security_controls
|
|
13
|
+
deployment contracts relationships operational_proof
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Review lifecycle of a fact.
|
|
17
|
+
STATUSES = %i[proposed accepted rejected edited].freeze
|
|
18
|
+
|
|
19
|
+
# @return [Symbol] a stable identifier within its repository, e.g. :rspec
|
|
20
|
+
attr_reader :key
|
|
21
|
+
# @return [Symbol] one of {CATEGORIES}
|
|
22
|
+
attr_reader :category
|
|
23
|
+
# @return [String] human-readable claim
|
|
24
|
+
attr_reader :claim
|
|
25
|
+
# @return [Object] the machine-usable value behind the claim
|
|
26
|
+
attr_reader :value
|
|
27
|
+
# @return [Confidence]
|
|
28
|
+
attr_reader :confidence
|
|
29
|
+
# @return [Provenance]
|
|
30
|
+
attr_reader :provenance
|
|
31
|
+
# @return [Symbol] one of {STATUSES}
|
|
32
|
+
attr_reader :status
|
|
33
|
+
|
|
34
|
+
def initialize(key:, category:, claim:, provenance:, value: true, confidence: Confidence::MEDIUM, status: :proposed)
|
|
35
|
+
raise UnknownError, "Unknown detector category #{category.inspect}." unless CATEGORIES.include?(category.to_sym)
|
|
36
|
+
raise UnknownError, "Unknown fact status #{status.inspect}." unless STATUSES.include?(status.to_sym)
|
|
37
|
+
|
|
38
|
+
@key = key.to_sym
|
|
39
|
+
@category = category.to_sym
|
|
40
|
+
@claim = claim
|
|
41
|
+
@value = value
|
|
42
|
+
@confidence = Confidence[confidence]
|
|
43
|
+
@provenance = provenance
|
|
44
|
+
@status = status.to_sym
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns a copy accepted by a human. Acceptance is what turns an inference
|
|
48
|
+
# into declared policy (section 06 conflict resolution rule 4).
|
|
49
|
+
# @return [DiscoveredFact]
|
|
50
|
+
def accept
|
|
51
|
+
with(status: :accepted)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns a copy the human explicitly rejected.
|
|
55
|
+
# @return [DiscoveredFact]
|
|
56
|
+
def reject
|
|
57
|
+
with(status: :rejected)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns a copy with an edited value, recorded as human-edited.
|
|
61
|
+
# @return [DiscoveredFact]
|
|
62
|
+
def edit(new_value)
|
|
63
|
+
with(value: new_value, status: :edited)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def accepted? = status == :accepted
|
|
67
|
+
|
|
68
|
+
def rejected? = status == :rejected
|
|
69
|
+
|
|
70
|
+
# A fact "counts" toward the contract when a human has not rejected it.
|
|
71
|
+
def active? = status != :rejected
|
|
72
|
+
|
|
73
|
+
def to_h
|
|
74
|
+
{
|
|
75
|
+
key: key,
|
|
76
|
+
category: category,
|
|
77
|
+
claim: claim,
|
|
78
|
+
value: value,
|
|
79
|
+
confidence: confidence.to_sym,
|
|
80
|
+
status: status,
|
|
81
|
+
provenance: provenance.to_h
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def with(**changes)
|
|
88
|
+
DiscoveredFact.new(
|
|
89
|
+
key: changes.fetch(:key, key),
|
|
90
|
+
category: changes.fetch(:category, category),
|
|
91
|
+
claim: changes.fetch(:claim, claim),
|
|
92
|
+
value: changes.fetch(:value, value),
|
|
93
|
+
confidence: changes.fetch(:confidence, confidence),
|
|
94
|
+
provenance: changes.fetch(:provenance, provenance),
|
|
95
|
+
status: changes.fetch(:status, status)
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# Runs every detector over a repository and collects their discovered facts
|
|
5
|
+
# (specification section 06). Discovery is strictly read-only and never
|
|
6
|
+
# decides policy: it produces proposals, each with provenance and confidence,
|
|
7
|
+
# for a human to confirm during contract review.
|
|
8
|
+
class Discovery
|
|
9
|
+
# The default detector set, ordered roughly ecosystem-first so that later
|
|
10
|
+
# reporting reads naturally.
|
|
11
|
+
DEFAULT_DETECTORS = [
|
|
12
|
+
Detectors::Bundler,
|
|
13
|
+
Detectors::RubyGem,
|
|
14
|
+
Detectors::RailsApp,
|
|
15
|
+
Detectors::Commands,
|
|
16
|
+
Detectors::GitHubActions,
|
|
17
|
+
Detectors::SecurityControls,
|
|
18
|
+
Detectors::Fork,
|
|
19
|
+
Detectors::OperationalProof,
|
|
20
|
+
Detectors::Relationships
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :repository, :detectors
|
|
24
|
+
|
|
25
|
+
def initialize(repository, detectors: DEFAULT_DETECTORS)
|
|
26
|
+
@repository = repository
|
|
27
|
+
@detectors = detectors
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [DiscoveryResult]
|
|
31
|
+
def run
|
|
32
|
+
facts = detectors.flat_map do |klass|
|
|
33
|
+
klass.new(repository).call
|
|
34
|
+
rescue => e
|
|
35
|
+
# A misbehaving detector must never sink the whole observation; record
|
|
36
|
+
# the failure and keep going. Robustness over completeness here - but the
|
|
37
|
+
# failure is logged with its class and origin so a swallowed bug stays
|
|
38
|
+
# diagnosable rather than silently dropping facts.
|
|
39
|
+
Reanimator.logger.warn(
|
|
40
|
+
"#{klass} failed on #{repository.name}: #{e.class}: #{e.message} (at #{e.backtrace&.first})"
|
|
41
|
+
)
|
|
42
|
+
[]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
DiscoveryResult.new(repository: repository, facts: facts)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reanimator
|
|
4
|
+
# The outcome of running discovery over one repository: the collected facts
|
|
5
|
+
# plus the profile they suggest. This is what the contract-review UI renders
|
|
6
|
+
# ("here is what we found, and why") and what the resolver consumes.
|
|
7
|
+
class DiscoveryResult
|
|
8
|
+
# The suggested profile with the reasoning behind it.
|
|
9
|
+
Suggestion = Struct.new(:profile, :confidence, :reason)
|
|
10
|
+
|
|
11
|
+
attr_reader :repository, :facts
|
|
12
|
+
|
|
13
|
+
def initialize(repository:, facts:)
|
|
14
|
+
@repository = repository
|
|
15
|
+
@facts = facts
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @return [DiscoveredFact, nil]
|
|
19
|
+
def [](key)
|
|
20
|
+
facts.find { |f| f.key == key.to_sym }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def fact?(key)
|
|
25
|
+
!self[key].nil?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The machine value of a fact, or nil. Used to pull discovered command
|
|
29
|
+
# strings into acceptance criteria.
|
|
30
|
+
def value(key)
|
|
31
|
+
self[key]&.value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param category [Symbol]
|
|
35
|
+
# @return [Array<DiscoveredFact>]
|
|
36
|
+
def in_category(category)
|
|
37
|
+
facts.select { |f| f.category == category.to_sym }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash{Symbol=>Array<DiscoveredFact>}] facts grouped by category,
|
|
41
|
+
# in the canonical category order
|
|
42
|
+
def by_category
|
|
43
|
+
DiscoveredFact::CATEGORIES.each_with_object({}) do |category, grouped|
|
|
44
|
+
found = in_category(category)
|
|
45
|
+
grouped[category] = found unless found.empty?
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The single source of truth mapping a profile criterion id to the
|
|
50
|
+
# discovered-command fact key that satisfies it. Note the ids and fact keys
|
|
51
|
+
# differ (e.g. :tests -> :test_command); this map keeps both
|
|
52
|
+
# {#discovered_commands} and provenance look-ups from drifting apart.
|
|
53
|
+
COMMAND_FACT_KEYS = {
|
|
54
|
+
tests: :test_command,
|
|
55
|
+
lint: :lint_command,
|
|
56
|
+
build: :build_command,
|
|
57
|
+
dependencies: :dependencies_command,
|
|
58
|
+
assets: :assets_command
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
# Map of discovered command facts -> command strings, keyed by the profile
|
|
62
|
+
# criterion they satisfy (:tests, :lint, :build, :dependencies, :assets).
|
|
63
|
+
# @return [Hash{Symbol=>String}]
|
|
64
|
+
def discovered_commands
|
|
65
|
+
COMMAND_FACT_KEYS.each_with_object({}) do |(criterion_id, fact_key), commands|
|
|
66
|
+
command = value(fact_key)
|
|
67
|
+
commands[criterion_id] = command if command
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The discovered-command fact backing a profile criterion, for provenance.
|
|
72
|
+
# @param criterion_id [Symbol]
|
|
73
|
+
# @return [DiscoveredFact, nil]
|
|
74
|
+
def command_fact(criterion_id)
|
|
75
|
+
fact_key = COMMAND_FACT_KEYS[criterion_id.to_sym]
|
|
76
|
+
fact_key && self[fact_key]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @return [Suggestion]
|
|
80
|
+
def suggestion
|
|
81
|
+
@suggestion ||= ProfileInference.call(self)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Symbol] the suggested profile id
|
|
85
|
+
def suggested_profile
|
|
86
|
+
suggestion.profile
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Reanimator
|
|
6
|
+
# Compares two contract snapshots and classifies every difference as either a
|
|
7
|
+
# policy change or a benign one (specification section 06).
|
|
8
|
+
#
|
|
9
|
+
# The classification rule is one question asked of each difference: *is less
|
|
10
|
+
# being proven than before?* A removed security gate, a narrowed runtime
|
|
11
|
+
# matrix, a dropped required obligation, a widened autonomy grant and a fresh
|
|
12
|
+
# waiver all answer yes, so they are policy changes and a human is asked to
|
|
13
|
+
# look. A renamed test command, an added check, a withdrawn waiver and a
|
|
14
|
+
# tightened matrix answer no - they are reported, but they do not interrupt
|
|
15
|
+
# anyone.
|
|
16
|
+
class DriftDetector
|
|
17
|
+
# A severity plus the sentence that justifies it. Every change carries the
|
|
18
|
+
# reasoning for its own classification, so nothing downstream has to guess
|
|
19
|
+
# why a difference did or did not require review.
|
|
20
|
+
Verdict = Struct.new(:severity, :explanation)
|
|
21
|
+
|
|
22
|
+
# Every judgement this detector can make, gathered in one place: "why did
|
|
23
|
+
# that need review?" is a question about policy, and the answer should not
|
|
24
|
+
# be scattered through the code that happened to notice the difference.
|
|
25
|
+
CHANGED_INTENT = Verdict.new(:policy, "A lifecycle decision changed; what the repository owes changes with it.")
|
|
26
|
+
CHANGED_PROFILE = Verdict.new(:policy, "The vitality profile changed, so the whole definition of healthy moved.")
|
|
27
|
+
CHANGED_POSTURE = Verdict.new(:policy, "The security posture changed, altering how strictly findings are treated.")
|
|
28
|
+
WIDENED_AUTONOMY = Verdict.new(:policy, "Autonomy was widened: the agent may now do more here than it could.")
|
|
29
|
+
NARROWED_AUTONOMY = Verdict.new(:benign, "Autonomy was narrowed; the agent may do less than it could.")
|
|
30
|
+
UNPROTECTED_PATH = Verdict.new(:policy, "A path lost its elevated review; changes there no longer need a human.")
|
|
31
|
+
PROTECTED_PATH = Verdict.new(:benign, "More of the repository is protected than before.")
|
|
32
|
+
NARROWED_MATRIX = Verdict.new(:policy, "The runtime matrix narrowed: a version that used to be exercised no longer is.")
|
|
33
|
+
WIDENED_MATRIX = Verdict.new(:benign, "The runtime matrix widened; more versions are exercised.")
|
|
34
|
+
REMOVED_CONTROL = Verdict.new(:policy, "A security gate was removed; it is no longer part of the proof.")
|
|
35
|
+
ADDED_CONTROL = Verdict.new(:benign, "A security gate was added.")
|
|
36
|
+
DROPPED_REQUIRED = Verdict.new(:policy, "A required obligation is gone; nothing proves it any more.")
|
|
37
|
+
DROPPED_OPTIONAL = Verdict.new(:benign, "An optional check is gone.")
|
|
38
|
+
ADDED_CRITERION = Verdict.new(:benign, "More is proven than before.")
|
|
39
|
+
RELAXED_OBLIGATION = Verdict.new(:policy, "An obligation stopped blocking; failing it no longer holds anything back.")
|
|
40
|
+
TIGHTENED_OBLIGATION = Verdict.new(:benign, "An obligation became blocking; more is required than before.")
|
|
41
|
+
MOVED_EVALUATOR = Verdict.new(:policy, "The way this obligation is proven changed, so old and new evidence are not comparable.")
|
|
42
|
+
# The specification's own example of drift that is *not* alarming: the
|
|
43
|
+
# obligation is identical, only the command spelling moved.
|
|
44
|
+
RENAMED_COMMAND = Verdict.new(:benign, "The same obligation is proven by a differently spelled command.")
|
|
45
|
+
ADDED_WAIVER = Verdict.new(:policy, "An obligation is now accepted as risk instead of proven.")
|
|
46
|
+
REMOVED_WAIVER = Verdict.new(:benign, "Accepted risk was withdrawn; the obligation is blocking again.")
|
|
47
|
+
EXTENDED_WAIVER = Verdict.new(:policy, "Accepted risk was extended, so the exception outlives the decision that made it.")
|
|
48
|
+
SHORTENED_WAIVER = Verdict.new(:benign, "Accepted risk was shortened.")
|
|
49
|
+
# Everything else here fails closed; an expiry nobody can read is not
|
|
50
|
+
# evidence that the risk got smaller, so it is not treated as if it were.
|
|
51
|
+
UNREADABLE_WAIVER = Verdict.new(:policy, "Accepted risk moved to an expiry that cannot be read, so it cannot be shown to be shorter.")
|
|
52
|
+
|
|
53
|
+
# Scalar fields whose value simply must not change unnoticed.
|
|
54
|
+
SCALAR_RULES = {
|
|
55
|
+
intent: CHANGED_INTENT,
|
|
56
|
+
profile: CHANGED_PROFILE,
|
|
57
|
+
security_posture: CHANGED_POSTURE
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
# How one set-valued part of a snapshot drifts. Membership changes are the
|
|
61
|
+
# same shape everywhere - something is in the old set, or the new one, or
|
|
62
|
+
# both - so the only thing that varies is what each direction means.
|
|
63
|
+
SetRule = Struct.new(:field, :subject, :on_removed, :on_added)
|
|
64
|
+
|
|
65
|
+
SET_RULES = [
|
|
66
|
+
SetRule.new(:protected_paths, "protected path", UNPROTECTED_PATH, PROTECTED_PATH),
|
|
67
|
+
SetRule.new(:support_matrix, "supported runtime", NARROWED_MATRIX, WIDENED_MATRIX),
|
|
68
|
+
SetRule.new(:security_controls, "security control", REMOVED_CONTROL, ADDED_CONTROL)
|
|
69
|
+
].freeze
|
|
70
|
+
|
|
71
|
+
# One comparable field of a criterion that both snapshots record. Whether
|
|
72
|
+
# the difference matters can depend on its direction, so each rule judges
|
|
73
|
+
# its own before/after rather than exposing a severity outright.
|
|
74
|
+
CriterionRule = Struct.new(:field, :subject, :judge) do
|
|
75
|
+
def verdict(before, after) = judge.call(before, after)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
CRITERION_RULES = [
|
|
79
|
+
CriterionRule.new(:required, "required", ->(before, _after) { before ? RELAXED_OBLIGATION : TIGHTENED_OBLIGATION }),
|
|
80
|
+
CriterionRule.new(:evaluator, "evaluator", ->(_before, _after) { MOVED_EVALUATOR }),
|
|
81
|
+
CriterionRule.new(:run, "command", ->(_before, _after) { RENAMED_COMMAND })
|
|
82
|
+
].freeze
|
|
83
|
+
|
|
84
|
+
# How a waiver with no expiry reads in a report.
|
|
85
|
+
OPEN_ENDED = "open-ended"
|
|
86
|
+
|
|
87
|
+
# Autonomy is a grant, not a scale: anything other than :disabled is a
|
|
88
|
+
# grant, and :automatic grants it without a human in the loop.
|
|
89
|
+
AUTONOMY_RANKS = {disabled: 0, manual: 1, proposal: 1, automatic: 2}.freeze
|
|
90
|
+
|
|
91
|
+
# @param previous [ContractSnapshot] the contract as last recorded
|
|
92
|
+
# @param current [ContractSnapshot] the contract the evidence resolves to now
|
|
93
|
+
# @return [DriftReport]
|
|
94
|
+
def self.between(previous, current) = new(previous, current).call
|
|
95
|
+
|
|
96
|
+
def initialize(previous, current)
|
|
97
|
+
@previous = previous
|
|
98
|
+
@current = current
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def call
|
|
102
|
+
ensure_same_repository
|
|
103
|
+
|
|
104
|
+
DriftReport.new(
|
|
105
|
+
repository: @current.repository, previous: @previous, current: @current,
|
|
106
|
+
changes: scalar_changes + autonomy_changes + set_changes + criteria_changes + waiver_changes
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Comparing two different repositories' snapshots would produce a report
|
|
113
|
+
# that reads plausibly and means nothing, and the dangerous direction is
|
|
114
|
+
# "no drift" - quietly reporting a repository unchanged that was never
|
|
115
|
+
# recorded. A git origin names a repository exactly, so it decides whenever
|
|
116
|
+
# both snapshots carry one; without it the recorded names are the only
|
|
117
|
+
# evidence there is, and two same-named local checkouts are
|
|
118
|
+
# indistinguishable.
|
|
119
|
+
def ensure_same_repository
|
|
120
|
+
recorded, current = identities
|
|
121
|
+
return if recorded.nil? || current.nil? || recorded == current
|
|
122
|
+
|
|
123
|
+
raise ContractError,
|
|
124
|
+
"Snapshot is for #{recorded}, not #{current}; drift is only meaningful within one repository."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def identities
|
|
128
|
+
if @previous.origin && @current.origin
|
|
129
|
+
[@previous.origin, @current.origin]
|
|
130
|
+
else
|
|
131
|
+
[@previous.repository, @current.repository]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def scalar_changes
|
|
136
|
+
SCALAR_RULES.filter_map do |field, verdict|
|
|
137
|
+
before = @previous.public_send(field)
|
|
138
|
+
after = @current.public_send(field)
|
|
139
|
+
next if before == after
|
|
140
|
+
|
|
141
|
+
change(field.to_s.tr("_", " "), :changed, verdict, from: before, to: after)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def autonomy_changes
|
|
146
|
+
(@previous.autonomy.keys | @current.autonomy.keys).filter_map do |rung|
|
|
147
|
+
before = @previous.autonomy[rung] || :disabled
|
|
148
|
+
after = @current.autonomy[rung] || :disabled
|
|
149
|
+
next if before == after
|
|
150
|
+
|
|
151
|
+
verdict = (rank(after) > rank(before)) ? WIDENED_AUTONOMY : NARROWED_AUTONOMY
|
|
152
|
+
change("autonomy #{rung}", :changed, verdict, from: before, to: after)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def rank(mode) = AUTONOMY_RANKS.fetch(mode.to_sym, 1)
|
|
157
|
+
|
|
158
|
+
def set_changes
|
|
159
|
+
SET_RULES.flat_map do |rule|
|
|
160
|
+
before = Array(@previous.public_send(rule.field))
|
|
161
|
+
after = Array(@current.public_send(rule.field))
|
|
162
|
+
|
|
163
|
+
(before - after).map { |value| change(rule.subject, :removed, rule.on_removed, from: value) } +
|
|
164
|
+
(after - before).map { |value| change(rule.subject, :added, rule.on_added, to: value) }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def criteria_changes
|
|
169
|
+
before_ids = @previous.criterion_ids
|
|
170
|
+
after_ids = @current.criterion_ids
|
|
171
|
+
|
|
172
|
+
(before_ids - after_ids).map { |id| dropped_criterion(id) } +
|
|
173
|
+
(after_ids - before_ids).map { |id| change("criterion", :added, ADDED_CRITERION, to: id) } +
|
|
174
|
+
(before_ids & after_ids).flat_map { |id| altered_criterion(id) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def dropped_criterion(id)
|
|
178
|
+
verdict = @previous.requires?(id) ? DROPPED_REQUIRED : DROPPED_OPTIONAL
|
|
179
|
+
change("criterion", :removed, verdict, from: id)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def altered_criterion(id)
|
|
183
|
+
before = @previous.criterion(id)
|
|
184
|
+
after = @current.criterion(id)
|
|
185
|
+
|
|
186
|
+
CRITERION_RULES.filter_map do |rule|
|
|
187
|
+
was = before[rule.field]
|
|
188
|
+
now = after[rule.field]
|
|
189
|
+
next if was == now
|
|
190
|
+
|
|
191
|
+
change("criterion #{id} #{rule.subject}", :changed, rule.verdict(was, now), from: was, to: now)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def waiver_changes
|
|
196
|
+
before_targets = @previous.waiver_targets
|
|
197
|
+
after_targets = @current.waiver_targets
|
|
198
|
+
|
|
199
|
+
(after_targets - before_targets).map { |target| change("waiver", :added, ADDED_WAIVER, to: target) } +
|
|
200
|
+
(before_targets - after_targets).map { |target| change("waiver", :removed, REMOVED_WAIVER, from: target) } +
|
|
201
|
+
(before_targets & after_targets).filter_map { |target| rescheduled_waiver(target) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def rescheduled_waiver(target)
|
|
205
|
+
before = @previous.waiver_expiry(target)
|
|
206
|
+
after = @current.waiver_expiry(target)
|
|
207
|
+
return if before == after
|
|
208
|
+
|
|
209
|
+
change("waiver #{target} expiry", :changed, expiry_verdict(before, after),
|
|
210
|
+
from: before || OPEN_ENDED, to: after || OPEN_ENDED)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# An open-ended waiver (no expiry) outlives every dated one.
|
|
214
|
+
def expiry_verdict(before, after)
|
|
215
|
+
return EXTENDED_WAIVER if after.nil?
|
|
216
|
+
return SHORTENED_WAIVER if before.nil?
|
|
217
|
+
|
|
218
|
+
(Time.parse(after) > Time.parse(before)) ? EXTENDED_WAIVER : SHORTENED_WAIVER
|
|
219
|
+
rescue ArgumentError, TypeError
|
|
220
|
+
UNREADABLE_WAIVER
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def change(subject, kind, verdict, from: nil, to: nil)
|
|
224
|
+
DriftReport::Change.new(subject: subject, kind: kind, verdict: verdict, from: from, to: to)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|