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.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +207 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +483 -0
  5. data/SECURITY.md +32 -0
  6. data/exe/reanimator +6 -0
  7. data/lib/reanimator/acceptance_criterion.rb +75 -0
  8. data/lib/reanimator/agent_connection.rb +62 -0
  9. data/lib/reanimator/agent_profile.rb +51 -0
  10. data/lib/reanimator/agents/adapter.rb +49 -0
  11. data/lib/reanimator/agents/capability.rb +29 -0
  12. data/lib/reanimator/agents/constraints.rb +57 -0
  13. data/lib/reanimator/agents/hosted.rb +38 -0
  14. data/lib/reanimator/agents/local.rb +46 -0
  15. data/lib/reanimator/agents/patch.rb +60 -0
  16. data/lib/reanimator/agents/repair_task.rb +51 -0
  17. data/lib/reanimator/agents/result.rb +42 -0
  18. data/lib/reanimator/agents/strategies/arithmetic_fix.rb +51 -0
  19. data/lib/reanimator/agents/strategies/base.rb +26 -0
  20. data/lib/reanimator/agents.rb +38 -0
  21. data/lib/reanimator/autonomy_level.rb +41 -0
  22. data/lib/reanimator/baseline.rb +106 -0
  23. data/lib/reanimator/baseline_runner.rb +81 -0
  24. data/lib/reanimator/bounded_command.rb +87 -0
  25. data/lib/reanimator/classifier.rb +89 -0
  26. data/lib/reanimator/cli.rb +273 -0
  27. data/lib/reanimator/cohort.rb +61 -0
  28. data/lib/reanimator/commissioning/fixture.rb +69 -0
  29. data/lib/reanimator/commissioning/report.rb +45 -0
  30. data/lib/reanimator/commissioning/test.rb +145 -0
  31. data/lib/reanimator/confidence.rb +66 -0
  32. data/lib/reanimator/configuration.rb +35 -0
  33. data/lib/reanimator/contract_file.rb +116 -0
  34. data/lib/reanimator/contract_snapshot.rb +188 -0
  35. data/lib/reanimator/criteria_stack.rb +91 -0
  36. data/lib/reanimator/criterion_result.rb +70 -0
  37. data/lib/reanimator/detectors/base.rb +49 -0
  38. data/lib/reanimator/detectors/bundler.rb +68 -0
  39. data/lib/reanimator/detectors/commands.rb +112 -0
  40. data/lib/reanimator/detectors/fork.rb +25 -0
  41. data/lib/reanimator/detectors/github_actions.rb +68 -0
  42. data/lib/reanimator/detectors/operational_proof.rb +48 -0
  43. data/lib/reanimator/detectors/rails_app.rb +51 -0
  44. data/lib/reanimator/detectors/relationships.rb +46 -0
  45. data/lib/reanimator/detectors/ruby_gem.rb +40 -0
  46. data/lib/reanimator/detectors/security_controls.rb +36 -0
  47. data/lib/reanimator/discovered_fact.rb +99 -0
  48. data/lib/reanimator/discovery.rb +48 -0
  49. data/lib/reanimator/discovery_result.rb +89 -0
  50. data/lib/reanimator/drift_detector.rb +227 -0
  51. data/lib/reanimator/drift_report.rb +86 -0
  52. data/lib/reanimator/effective_contract.rb +108 -0
  53. data/lib/reanimator/effective_policy_resolver.rb +116 -0
  54. data/lib/reanimator/errors.rb +21 -0
  55. data/lib/reanimator/evaluators/base.rb +55 -0
  56. data/lib/reanimator/evaluators/command.rb +34 -0
  57. data/lib/reanimator/evaluators/documentation_check.rb +25 -0
  58. data/lib/reanimator/evaluators/http_smoke.rb +19 -0
  59. data/lib/reanimator/evaluators/repository_integrity.rb +22 -0
  60. data/lib/reanimator/evaluators/security_evidence.rb +24 -0
  61. data/lib/reanimator/evaluators/support_matrix.rb +25 -0
  62. data/lib/reanimator/evaluators.rb +41 -0
  63. data/lib/reanimator/family/gate.rb +40 -0
  64. data/lib/reanimator/family/gate_result.rb +46 -0
  65. data/lib/reanimator/family/gate_runner.rb +91 -0
  66. data/lib/reanimator/family.rb +89 -0
  67. data/lib/reanimator/family_inference.rb +113 -0
  68. data/lib/reanimator/git.rb +72 -0
  69. data/lib/reanimator/isolation.rb +36 -0
  70. data/lib/reanimator/language_inference.rb +43 -0
  71. data/lib/reanimator/lifecycle_intent.rb +93 -0
  72. data/lib/reanimator/obligation.rb +35 -0
  73. data/lib/reanimator/permission_model.rb +61 -0
  74. data/lib/reanimator/portfolio.rb +68 -0
  75. data/lib/reanimator/portfolio_drift.rb +120 -0
  76. data/lib/reanimator/profile_inference.rb +37 -0
  77. data/lib/reanimator/profiles.rb +110 -0
  78. data/lib/reanimator/proof_of_life.rb +45 -0
  79. data/lib/reanimator/proposed_change.rb +55 -0
  80. data/lib/reanimator/provenance.rb +39 -0
  81. data/lib/reanimator/relationship.rb +62 -0
  82. data/lib/reanimator/repair_run.rb +146 -0
  83. data/lib/reanimator/reports/agents.rb +28 -0
  84. data/lib/reanimator/reports/baseline.rb +73 -0
  85. data/lib/reanimator/reports/commissioning.rb +48 -0
  86. data/lib/reanimator/reports/contract.rb +70 -0
  87. data/lib/reanimator/reports/discovery.rb +38 -0
  88. data/lib/reanimator/reports/drift.rb +63 -0
  89. data/lib/reanimator/reports/families.rb +41 -0
  90. data/lib/reanimator/reports/family_gates.rb +41 -0
  91. data/lib/reanimator/reports/github_check.rb +45 -0
  92. data/lib/reanimator/reports/inventory.rb +37 -0
  93. data/lib/reanimator/reports/permissions.rb +32 -0
  94. data/lib/reanimator/reports/portfolio_baseline.rb +68 -0
  95. data/lib/reanimator/reports/portfolio_drift.rb +107 -0
  96. data/lib/reanimator/reports/run.rb +52 -0
  97. data/lib/reanimator/reports/snapshot.rb +58 -0
  98. data/lib/reanimator/reports.rb +36 -0
  99. data/lib/reanimator/repository.rb +96 -0
  100. data/lib/reanimator/repository_metadata.rb +78 -0
  101. data/lib/reanimator/repository_state.rb +48 -0
  102. data/lib/reanimator/routing/decision.rb +35 -0
  103. data/lib/reanimator/routing/router.rb +122 -0
  104. data/lib/reanimator/run.rb +48 -0
  105. data/lib/reanimator/safety_floor.rb +81 -0
  106. data/lib/reanimator/security_control.rb +87 -0
  107. data/lib/reanimator/security_posture.rb +80 -0
  108. data/lib/reanimator/snapshot_store.rb +78 -0
  109. data/lib/reanimator/task_class.rb +63 -0
  110. data/lib/reanimator/version.rb +5 -0
  111. data/lib/reanimator/vitality_profile.rb +44 -0
  112. data/lib/reanimator/waiver.rb +74 -0
  113. data/lib/reanimator.rb +214 -0
  114. 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