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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A suggested triage grouping for a repository during intake (specification
5
+ # section 04). Cohorts exist so a maintainer can accept sensible defaults in
6
+ # bulk while still seeing - and overriding - the intent behind each grouping.
7
+ # Crucially, the tool never preselects every repository; it recommends a
8
+ # small, high-confidence set that can reach a useful baseline quickly.
9
+ class Cohort
10
+ attr_reader :key, :title, :default_intent, :treatment
11
+
12
+ def initialize(key:, title:, default_intent:, treatment:)
13
+ @key = key
14
+ @title = title
15
+ @default_intent = default_intent
16
+ @treatment = treatment
17
+ freeze
18
+ end
19
+
20
+ # Whether repositories in this cohort are recommended for active
21
+ # onboarding (versus deferred, observed, or preserved).
22
+ def recommended_for_onboarding?
23
+ key == :active_original
24
+ end
25
+
26
+ ALL = {
27
+ active_original: new(
28
+ key: :active_original, title: "Active original repositories",
29
+ default_intent: :maintain, treatment: "Recommend for onboarding."
30
+ ),
31
+ recent_experiment: new(
32
+ key: :recent_experiment, title: "Recent experiments",
33
+ default_intent: :incubate, treatment: "Suggest incubate or monitor."
34
+ ),
35
+ readme_only: new(
36
+ key: :readme_only, title: "README-only concepts and presentations",
37
+ default_intent: :incubate, treatment: "Ask whether to incubate, preserve, or ignore."
38
+ ),
39
+ archived: new(
40
+ key: :archived, title: "Archived repositories",
41
+ default_intent: :preserve, treatment: "Suggest preserve or retire; never auto-modernize."
42
+ ),
43
+ old_unarchived: new(
44
+ key: :old_unarchived, title: "Old unarchived repositories",
45
+ default_intent: :monitor, treatment: "Flag intent ambiguity rather than assuming maintenance."
46
+ ),
47
+ fork_observation: new(
48
+ key: :fork_observation, title: "Forks",
49
+ default_intent: :upstream, treatment: "Upstream observation until unique-commit intent is confirmed."
50
+ ),
51
+ constrained: new(
52
+ key: :constrained, title: "Hardware or credential-dependent projects",
53
+ default_intent: :monitor, treatment: "Suggest a constrained-environment profile."
54
+ )
55
+ }.freeze
56
+
57
+ def self.fetch(key)
58
+ ALL.fetch(key.to_sym) { raise UnknownError, "Unknown cohort #{key.inspect}." }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+
6
+ module Reanimator
7
+ module Commissioning
8
+ # Builds a disposable fixture repository with a single, seeded defect
9
+ # (specification section 08 step 2: "run a synthetic repair against a
10
+ # disposable fixture repository"). The fixture ships an addition helper that
11
+ # wrongly subtracts and a required check that fails because of it, plus a
12
+ # protected verifier file the agent must not touch. Nothing here depends on
13
+ # Bundler, so commissioning is fast and hermetic.
14
+ module Fixture
15
+ BUGGY_LIB = <<~RUBY
16
+ # frozen_string_literal: true
17
+
18
+ module Calc
19
+ module_function
20
+
21
+ def add(a, b)
22
+ a - b
23
+ end
24
+ end
25
+ RUBY
26
+
27
+ CHECK = <<~RUBY
28
+ # frozen_string_literal: true
29
+
30
+ require "calc"
31
+
32
+ raise "add(2, 2) should be 4, got \#{Calc.add(2, 2)}" unless Calc.add(2, 2) == 4
33
+ puts "ok"
34
+ RUBY
35
+
36
+ CONTRACT = <<~YAML
37
+ version: 1
38
+ intent: maintain
39
+ profile: generic
40
+ acceptance:
41
+ protected_paths:
42
+ - check.rb
43
+ checks:
44
+ - id: arithmetic
45
+ claim: "add(2, 2) equals 4."
46
+ run: "ruby -Ilib check.rb"
47
+ required: true
48
+ YAML
49
+
50
+ module_function
51
+
52
+ # @return [String] path to a fresh disposable fixture
53
+ def build
54
+ dir = Dir.mktmpdir("reanimator-commission")
55
+ write(dir, "lib/calc.rb", BUGGY_LIB)
56
+ write(dir, "check.rb", CHECK)
57
+ write(dir, ".reanimator.yml", CONTRACT)
58
+ write(dir, "README.md", "# Commissioning fixture\n")
59
+ dir
60
+ end
61
+
62
+ def write(dir, relative, contents)
63
+ full = File.join(dir, relative)
64
+ FileUtils.mkdir_p(File.dirname(full))
65
+ File.write(full, contents)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ module Commissioning
5
+ # The outcome of a commissioning test (specification section 08 step 6:
6
+ # "mark the profile ready, degraded, incompatible, or unavailable"), plus
7
+ # the observable facts step 5 requires: cost, latency, permissions, tool
8
+ # use, and any network destinations. Reanimator computes the verdict
9
+ # itself from independent evaluation; the agent never grades its own work.
10
+ class Report
11
+ attr_reader :provider, :readiness, :reason, :capabilities, :objective,
12
+ :pre_state, :post_state, :cost_usd, :latency_s, :tool_use, :network
13
+
14
+ def initialize(provider:, readiness:, reason: nil, capabilities: [], objective: nil,
15
+ pre_state: nil, post_state: nil, cost_usd: 0.0, latency_s: 0.0, tool_use: [], network: [])
16
+ @provider = provider
17
+ @readiness = readiness.to_sym
18
+ @reason = reason
19
+ @capabilities = capabilities
20
+ @objective = objective
21
+ @pre_state = pre_state
22
+ @post_state = post_state
23
+ @cost_usd = cost_usd
24
+ @latency_s = latency_s
25
+ @tool_use = tool_use
26
+ @network = network
27
+ end
28
+
29
+ def ready? = readiness == :ready
30
+
31
+ # The whole point of the slice: a profile can be proven ready without any
32
+ # repository write. Commissioning only ever touches disposable fixtures.
33
+ def proven_without_writes? = true
34
+
35
+ def to_h
36
+ {
37
+ provider: provider, readiness: readiness, reason: reason,
38
+ capabilities: capabilities, objective: objective,
39
+ pre_state: pre_state, post_state: post_state,
40
+ cost_usd: cost_usd, latency_s: latency_s, tool_use: tool_use, network: network
41
+ }.compact
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+
6
+ module Reanimator
7
+ module Commissioning
8
+ # Runs the six-step commissioning test from specification section 08:
9
+ # validate credentials and capabilities, run a synthetic repair against a
10
+ # disposable fixture, require a structured plan and patch, evaluate the
11
+ # result *independently*, report the observable cost/latency/tools/network,
12
+ # and mark the profile ready/degraded/incompatible/unavailable.
13
+ #
14
+ # Verifier isolation (section 09) is structural here: the agent works in its
15
+ # own copy and returns a patch as data; the harness applies that patch to a
16
+ # *separate, clean* copy and grades it with Reanimator's own evaluators.
17
+ # A patch that reaches for a protected or verifier path is refused - the
18
+ # anti-gaming rule, enforced.
19
+ class Test
20
+ ALLOWED_EDIT_PATHS = ["lib/**", "*.rb"].freeze
21
+
22
+ def initialize(adapter, fixture: Fixture)
23
+ @adapter = adapter
24
+ @fixture = fixture
25
+ end
26
+
27
+ # @return [Commissioning::Report]
28
+ def run
29
+ return unavailable unless @adapter.available?
30
+
31
+ fixture_path = @fixture.build
32
+ scratch = [fixture_path]
33
+ begin
34
+ contract = contract_for(fixture_path)
35
+ pre = BaselineRunner.new(contract, execute: true).run
36
+ objective = pre.results.find { |r| r.required? && r.failed? }&.criterion
37
+ return incompatible("The fixture did not present a failing required objective.", pre: pre) unless objective
38
+
39
+ workspace = copy(fixture_path, scratch)
40
+ result = @adapter.repair(build_task(objective, workspace, contract, pre))
41
+ return incompatible("The agent returned no structured plan and patch.", pre: pre, result: result) unless result.structured?
42
+
43
+ if (violation = protected_violation(build_task(objective, workspace, contract, pre), result))
44
+ return incompatible(violation, pre: pre, result: result)
45
+ end
46
+
47
+ eval_dir = copy(fixture_path, scratch)
48
+ result.patch.apply(eval_dir)
49
+ post = BaselineRunner.new(contract_for(eval_dir), execute: true).run
50
+
51
+ verdict(objective, pre, post, result)
52
+ ensure
53
+ scratch.each { |dir| FileUtils.remove_entry(dir, true) }
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def contract_for(path)
60
+ EffectivePolicyResolver.new(Repository.at(path)).resolve
61
+ end
62
+
63
+ def build_task(objective, workspace, contract, pre)
64
+ Agents::RepairTask.new(
65
+ objective: objective, workspace: workspace, evidence: pre,
66
+ constraints: Agents::Constraints.new(
67
+ allowed_paths: ALLOWED_EDIT_PATHS,
68
+ protected_paths: contract.protected_paths,
69
+ max_cost_usd: @adapter.connection.max_cost_usd,
70
+ code_egress: @adapter.code_egress
71
+ )
72
+ )
73
+ end
74
+
75
+ # Anti-gaming (section 09): a repair may not edit a protected or
76
+ # out-of-scope path. This is what stops a task from rewriting the rules of
77
+ # proof and then passing under the new rules.
78
+ def protected_violation(task, result)
79
+ bad = result.patch.paths.reject { |path| task.may_edit?(path) }
80
+ "The patch attempted to edit protected or out-of-scope paths: #{bad.join(", ")}." unless bad.empty?
81
+ end
82
+
83
+ def verdict(objective, pre, post, result)
84
+ target = post.results.find { |r| r.criterion.id == objective.id }
85
+ regressions = regressed(pre, post)
86
+ over_budget = result.cost_usd > @adapter.connection.max_cost_usd
87
+
88
+ if target&.verified? && regressions.empty? && !over_budget
89
+ report(:ready, pre: pre, post: post, result: result, objective: objective)
90
+ else
91
+ report(:degraded, pre: pre, post: post, result: result, objective: objective,
92
+ reason: degraded_reason(target, regressions, over_budget))
93
+ end
94
+ end
95
+
96
+ # Required criteria that were verified before the repair but are not
97
+ # after it - genuine regressions the patch must not introduce.
98
+ def regressed(pre, post)
99
+ proven = pre.results.select { |r| r.required? && r.verified? }.map { |r| r.criterion.id }
100
+ post.results.select { |r| proven.include?(r.criterion.id) && !r.verified? }.map { |r| r.criterion.id }
101
+ end
102
+
103
+ def degraded_reason(target, regressions, over_budget)
104
+ if target.nil? || !target.verified?
105
+ "The patch did not resolve the objective."
106
+ elsif over_budget
107
+ "The repair exceeded the connection budget."
108
+ else
109
+ "The repair regressed: #{regressions.join(", ")}."
110
+ end
111
+ end
112
+
113
+ def copy(source, scratch)
114
+ destination = Dir.mktmpdir("reanimator-ws")
115
+ FileUtils.cp_r(File.join(source, "."), destination)
116
+ scratch << destination
117
+ destination
118
+ end
119
+
120
+ def unavailable
121
+ report(:unavailable, reason: @adapter.unavailable_reason)
122
+ end
123
+
124
+ def incompatible(reason, **rest)
125
+ report(:incompatible, reason: reason, **rest)
126
+ end
127
+
128
+ def report(readiness, pre: nil, post: nil, result: nil, objective: nil, reason: nil)
129
+ Report.new(
130
+ provider: @adapter.provider,
131
+ readiness: readiness,
132
+ reason: reason,
133
+ capabilities: @adapter.capabilities,
134
+ objective: objective&.id,
135
+ pre_state: pre&.state&.to_sym,
136
+ post_state: post&.state&.to_sym,
137
+ cost_usd: result&.cost_usd || 0.0,
138
+ latency_s: result&.latency_s || 0.0,
139
+ tool_use: result&.tool_use || [],
140
+ network: result&.network || []
141
+ )
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # How strongly a discovered fact is believed, and - just as importantly -
5
+ # what the product is allowed to do with that belief (specification section
6
+ # 06). Confidence never silently becomes authority: even a high-confidence
7
+ # fact is only *preselected*, and it still asks for confirmation when it
8
+ # would affect permissions, cost, privacy, or action authority.
9
+ class Confidence
10
+ include Comparable
11
+
12
+ LEVELS = %i[low medium high].freeze
13
+
14
+ BEHAVIOR = {
15
+ high: "Preselect and ask for confirmation only when the fact affects " \
16
+ "permissions, cost, privacy, or action authority.",
17
+ medium: "Show as a suggestion with evidence and alternatives.",
18
+ low: "Use to prioritize a question; never convert directly into an obligation."
19
+ }.freeze
20
+
21
+ attr_reader :level
22
+
23
+ def initialize(level)
24
+ @level = level.to_sym
25
+ unless LEVELS.include?(@level)
26
+ raise UnknownError, "Unknown confidence #{level.inspect}. Known: #{LEVELS.join(", ")}."
27
+ end
28
+ freeze
29
+ end
30
+
31
+ HIGH = new(:high)
32
+ MEDIUM = new(:medium)
33
+ LOW = new(:low)
34
+
35
+ def self.[](level)
36
+ case level
37
+ when Confidence then level
38
+ when :high, "high" then HIGH
39
+ when :medium, "medium" then MEDIUM
40
+ when :low, "low" then LOW
41
+ else new(level)
42
+ end
43
+ end
44
+
45
+ # Product behavior mandated for this confidence level.
46
+ # @return [String]
47
+ def behavior = BEHAVIOR.fetch(level)
48
+
49
+ # High-confidence facts may be preselected in the review UI; lower ones may
50
+ # only be suggested or used to prioritize a question.
51
+ def preselectable? = level == :high
52
+
53
+ # A low-confidence fact must never be converted directly into an
54
+ # obligation - it can only shape a question.
55
+ def obligatable? = level != :low
56
+
57
+ def <=>(other)
58
+ return nil unless other.is_a?(Confidence)
59
+ LEVELS.index(level) <=> LEVELS.index(other.level)
60
+ end
61
+
62
+ def to_sym = level
63
+
64
+ def to_s = level.to_s
65
+ end
66
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Process-wide configuration. Everything here has a safe, quiet default so
5
+ # the library is useful with zero setup - configuration is for tightening,
6
+ # not for enabling basic behavior.
7
+ class Configuration
8
+ # The file a repository owner may commit to declare intent and overrides.
9
+ DEFAULT_CONTRACT_FILENAME = ".reanimator.yml"
10
+
11
+ # @return [Symbol] the security posture applied when a repository does not
12
+ # declare one (:strict, :balanced, :local, :custom). Section 20 sets
13
+ # :balanced for public repositories as the v1 default.
14
+ attr_accessor :security_posture
15
+
16
+ # @return [String] the filename scanned for a repository-owned contract.
17
+ attr_accessor :contract_filename
18
+
19
+ # @return [Integer] wall-clock ceiling, in seconds, for any single check
20
+ # executed during a baseline. Baselines are bounded by construction.
21
+ attr_accessor :command_timeout
22
+
23
+ # @return [Boolean] whether baseline checks may execute at all. Even when
24
+ # true, execution is opt-in per run; when false the runner only ever
25
+ # plans, never spawns a process.
26
+ attr_accessor :allow_execution
27
+
28
+ def initialize
29
+ @security_posture = (ENV["REANIMATOR_SECURITY_POSTURE"] || "balanced").to_sym
30
+ @contract_filename = ENV["REANIMATOR_CONTRACT_FILE"] || DEFAULT_CONTRACT_FILENAME
31
+ @command_timeout = Integer(ENV["REANIMATOR_COMMAND_TIMEOUT"] || 900)
32
+ @allow_execution = ENV["REANIMATOR_ALLOW_EXECUTION"] != "false"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+
6
+ module Reanimator
7
+ # Reads and writes the optional, repository-owned .reanimator.yml
8
+ # (specification section 07). The target for ordinary configuration is tiny -
9
+ # "one lifecycle decision and one reusable profile" - but the same file can
10
+ # carry the full resolved schema for advanced owners. Export must round-trip
11
+ # without semantic loss, so this class is the single reader and writer for
12
+ # that format.
13
+ class ContractFile
14
+ CURRENT_VERSION = 1
15
+
16
+ # @return [Hash] the parsed document with symbol keys
17
+ attr_reader :data
18
+ # @return [String, nil] path the document was loaded from
19
+ attr_reader :source_path
20
+
21
+ def initialize(data, source_path: nil)
22
+ @data = normalize(data)
23
+ @source_path = source_path
24
+ validate!
25
+ end
26
+
27
+ # Loads the contract file from a repository root, or returns nil when the
28
+ # owner has not committed one (the common, YAML-free case).
29
+ #
30
+ # @param root [String]
31
+ # @param filename [String]
32
+ # @return [ContractFile, nil]
33
+ def self.load(root, filename = Reanimator.configuration.contract_filename)
34
+ full = File.join(root, filename)
35
+ return nil unless File.file?(full)
36
+
37
+ # Date/Time are permitted so a waiver's `expires:` can be a bare date
38
+ # literal; no other non-primitive classes are allowed to load.
39
+ raw = YAML.safe_load_file(full, permitted_classes: [Date, Time], aliases: false) || {}
40
+ new(raw, source_path: full)
41
+ rescue Psych::SyntaxError => e
42
+ raise ContractError, "#{filename} is not valid YAML: #{e.message}"
43
+ end
44
+
45
+ # Serializes a contract hash (typically {EffectiveContract#to_contract_hash})
46
+ # back to YAML. Keys are stringified and ordered for a stable, diff-friendly
47
+ # export.
48
+ #
49
+ # @param hash [Hash]
50
+ # @return [String]
51
+ def self.dump(hash)
52
+ YAML.dump(stringify(hash)).sub(/\A---\n/, "")
53
+ end
54
+
55
+ def version = data.fetch(:version, CURRENT_VERSION)
56
+
57
+ # @return [Symbol, nil]
58
+ def intent
59
+ value = data[:intent]
60
+ value&.to_sym
61
+ end
62
+
63
+ # @return [Symbol, nil]
64
+ def profile
65
+ value = data[:profile]
66
+ value&.to_sym
67
+ end
68
+
69
+ def agents = data[:agents] || {}
70
+
71
+ def acceptance = data[:acceptance] || {}
72
+
73
+ def security = data[:security] || {}
74
+
75
+ def autonomy = data[:autonomy] || {}
76
+
77
+ def upgrades = data[:upgrades] || {}
78
+
79
+ def notifications = data[:notifications] || {}
80
+
81
+ def waivers = data[:waivers] || []
82
+
83
+ def family = data[:family]
84
+
85
+ def to_yaml = ContractFile.dump(data)
86
+
87
+ private
88
+
89
+ def validate!
90
+ unless version.is_a?(Integer)
91
+ raise ContractError, "Contract version must be an integer, got #{version.inspect}."
92
+ end
93
+ if intent && !LifecycleIntent.valid?(intent)
94
+ raise ContractError, "Unknown lifecycle intent #{intent.inspect} in contract."
95
+ end
96
+ end
97
+
98
+ def normalize(value)
99
+ case value
100
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_sym] = normalize(v) }
101
+ when Array then value.map { |v| normalize(v) }
102
+ else value
103
+ end
104
+ end
105
+
106
+ def self.stringify(value)
107
+ case value
108
+ when Hash then value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify(v) }
109
+ when Array then value.map { |v| stringify(v) }
110
+ when Symbol then value.to_s
111
+ else value
112
+ end
113
+ end
114
+ private_class_method :stringify
115
+ end
116
+ end