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,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # The product safety floor (specification section 07): the non-bypassable
5
+ # bottom of the precedence stack. No layer above it - organization,
6
+ # portfolio, family, repository, or task - may loosen these. The floor is
7
+ # what makes the anti-gaming rule enforceable: "a task that modifies the
8
+ # rules of proof cannot use those modified rules to approve itself."
9
+ module SafetyFloor
10
+ # Paths that always receive elevated scrutiny because editing them can
11
+ # weaken a check, change who is trusted, or alter what "proof" means. A
12
+ # repository contract may *add* to this set but never remove from it.
13
+ PROTECTED_PATHS = [
14
+ ".reanimator.yml",
15
+ ".github/workflows/**",
16
+ ".github/dependabot.yml",
17
+ "CODEOWNERS",
18
+ ".github/CODEOWNERS"
19
+ ].freeze
20
+
21
+ # Merge is off in v1 regardless of what any layer requests (section 18:
22
+ # automatic merge is explicitly later).
23
+ MERGE_IN_V1 = :disabled
24
+
25
+ module_function
26
+
27
+ # The union of the floor's protected paths and any the contract declares.
28
+ # @param declared [Array<String>]
29
+ # @return [Array<String>]
30
+ def protected_paths(declared = [])
31
+ (PROTECTED_PATHS + Array(declared)).uniq
32
+ end
33
+
34
+ # Matches a repository-relative path against a glob, treating a trailing
35
+ # `**` as "this directory and everything under it, at any depth". Plain
36
+ # `File.fnmatch?` with FNM_PATHNAME does not let `**` cross `/`, which would
37
+ # leave `.github/workflows/nested/deploy.yml` unprotected - so the recursive
38
+ # form is expanded and tried as well.
39
+ #
40
+ # @param glob [String]
41
+ # @param path [String]
42
+ # @return [Boolean]
43
+ def matches?(glob, path)
44
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB
45
+ return true if File.fnmatch?(glob, path, flags)
46
+
47
+ recursive = glob.sub(%r{/\*\*\z}, "/**/*")
48
+ recursive != glob && File.fnmatch?(recursive, path, flags)
49
+ end
50
+
51
+ # Whether any of the given globs protects the path.
52
+ def protects?(path, globs)
53
+ globs.any? { |glob| matches?(glob, path) }
54
+ end
55
+
56
+ # Clamps an autonomy map so it can never exceed the floor. Returns the
57
+ # clamped map plus a list of human-readable notes describing what was
58
+ # tightened, so the resolver can surface the clamp rather than hide it.
59
+ #
60
+ # @param autonomy [Hash]
61
+ # @return [Array(Hash, Array<String>)]
62
+ def clamp_autonomy(autonomy)
63
+ clamped = autonomy.dup
64
+ notes = []
65
+
66
+ if clamped[:merge] && clamped[:merge].to_sym != MERGE_IN_V1
67
+ notes << "Merge autonomy was requested but is disabled by the v1 safety floor."
68
+ clamped[:merge] = MERGE_IN_V1
69
+ end
70
+ clamped[:merge] ||= MERGE_IN_V1
71
+
72
+ [clamped, notes]
73
+ end
74
+
75
+ # Verifier isolation is always on: evaluation runs separately from any
76
+ # agent workspace, and an acting agent can never alter the effective
77
+ # contract, hidden tests, or baseline evidence during an ordinary repair
78
+ # (section 09). This is a constant, not a setting.
79
+ def verifier_isolation? = true
80
+ end
81
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A family of security evidence a posture can require (specification section
5
+ # 12). Controls are named by *obligation* rather than by tool: "dependency
6
+ # risk is reviewed" is satisfied by Dependabot, bundler-audit, or the
7
+ # dependency-review action alike, so a repository that already solved the
8
+ # problem its own way is credited rather than asked to adopt ours.
9
+ class SecurityControl
10
+ REGISTRY = {
11
+ dependency_review: {
12
+ claim: "Dependency risk is reviewed automatically.",
13
+ satisfied_by: %i[dependabot security_dependency_review security_bundler_dependency_audit].freeze,
14
+ remedy: "No dependency review found. Add .github/dependabot.yml, bundler-audit, or actions/dependency-review-action."
15
+ },
16
+ secret_scanning: {
17
+ claim: "Committed secrets are scanned for.",
18
+ satisfied_by: %i[security_secret_scanning_gitleaks security_secret_scanning_trufflehog].freeze,
19
+ remedy: "No secret scanning found. Add gitleaks or trufflehog to CI."
20
+ },
21
+ static_analysis: {
22
+ claim: "Source is scanned for known vulnerability patterns.",
23
+ satisfied_by: %i[security_codeql_sast security_brakeman_sast].freeze,
24
+ remedy: "No static analysis found. Add CodeQL or Brakeman to CI."
25
+ },
26
+ security_policy: {
27
+ claim: "A security policy says how to report a vulnerability.",
28
+ satisfied_by: %i[security_policy].freeze,
29
+ remedy: "No security policy found. Add SECURITY.md."
30
+ },
31
+ ownership: {
32
+ claim: "Ownership of protected paths is declared.",
33
+ satisfied_by: %i[codeowners].freeze,
34
+ remedy: "No ownership declaration found. Add CODEOWNERS."
35
+ }
36
+ }.freeze
37
+
38
+ attr_reader :id, :claim, :satisfied_by, :remedy
39
+
40
+ def initialize(id)
41
+ @id = id.to_sym
42
+ spec = REGISTRY.fetch(@id) do
43
+ raise UnknownError, "Unknown security control #{id.inspect}. Known: #{REGISTRY.keys.join(", ")}."
44
+ end
45
+ @claim = spec[:claim]
46
+ @satisfied_by = spec[:satisfied_by]
47
+ @remedy = spec[:remedy]
48
+ freeze
49
+ end
50
+
51
+ def self.fetch(id) = new(id)
52
+
53
+ def self.ids = REGISTRY.keys
54
+
55
+ def self.all = ids.map { |id| new(id) }
56
+
57
+ # How a set of controls reads in a report: their plain names, or "none" -
58
+ # so a posture that asks for nothing says so instead of trailing off.
59
+ def self.labels(ids)
60
+ return "none" if ids.empty?
61
+
62
+ ids.map { |id| fetch(id).label }.join(", ")
63
+ end
64
+
65
+ # The criterion id this control is graded under. The control owns its own
66
+ # naming so reports, waivers, and history cannot drift apart. Ids are
67
+ # already namespaced under "security-", so a control named for security
68
+ # drops the prefix rather than stuttering as security-security-policy.
69
+ def criterion_id = :"security-#{id.to_s.delete_prefix("security_").tr("_", "-")}"
70
+
71
+ # The first discovered fact that satisfies this control, or nil. Any one of
72
+ # several fingerprints counts: the obligation is what matters, not the tool.
73
+ #
74
+ # @param discovery [DiscoveryResult]
75
+ # @return [DiscoveredFact, nil]
76
+ def evidence_in(discovery)
77
+ satisfied_by.filter_map { |key| discovery[key] }.first
78
+ end
79
+
80
+ # Human-readable name for reports, e.g. "dependency review".
81
+ def label = id.to_s.tr("_", " ")
82
+
83
+ def to_sym = id
84
+
85
+ def to_s = id.to_s
86
+ end
87
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # The security posture presets from specification section 12. Security is
5
+ # part of onboarding, not a hidden settings page, so the posture is a
6
+ # first-class, named value with plain-language behavior - never a bundle of
7
+ # silent relaxations.
8
+ class SecurityPosture
9
+ PRESETS = {
10
+ strict: "Approved providers only, deny-by-default network and secrets, " \
11
+ "protected workflows, no auto-merge, enhanced review for verifier changes.",
12
+ balanced: "Approved package-registry network, hosted agents allowed, " \
13
+ "patch submission permitted, no major automatic repair.",
14
+ local: "Code and execution remain on user-controlled runners; external providers excluded.",
15
+ custom: "Explicit controls with no hidden relaxation from a preset."
16
+ }.freeze
17
+
18
+ # What each posture makes mandatory (section 12). The posture - not the
19
+ # project's shape - decides how much security proof a repository must show,
20
+ # so the same gem is advisory under :balanced and blocking under :strict.
21
+ # A control absent from a posture's map is not asked for at all.
22
+ #
23
+ # :local requires secret scanning because committed secrets are the one
24
+ # risk that does not care whether the runner is yours. :custom implies
25
+ # nothing: "no hidden relaxation from a preset" cuts both ways, so the
26
+ # owner declares the controls they want and Reanimator adds none.
27
+ OBLIGATIONS = {
28
+ strict: {
29
+ security_policy: true, dependency_review: true, secret_scanning: true,
30
+ static_analysis: false, ownership: false
31
+ },
32
+ balanced: {dependency_review: false, secret_scanning: false, security_policy: false},
33
+ local: {secret_scanning: true, dependency_review: false},
34
+ custom: {}
35
+ }.freeze
36
+
37
+ attr_reader :name
38
+
39
+ def initialize(name)
40
+ @name = name.to_sym
41
+ unless PRESETS.key?(@name)
42
+ raise UnknownError, "Unknown security posture #{name.inspect}. Known: #{PRESETS.keys.join(", ")}."
43
+ end
44
+ freeze
45
+ end
46
+
47
+ def self.[](name) = new(name)
48
+
49
+ # Section 20 v1 default: balanced for public repositories, strict for
50
+ # private repositories unless changed.
51
+ def self.default_for(visibility)
52
+ (visibility.to_s == "private") ? new(:strict) : new(:balanced)
53
+ end
54
+
55
+ def description = PRESETS.fetch(name)
56
+
57
+ # Network egress the posture permits by default.
58
+ def network
59
+ case name
60
+ when :strict, :local then :deny
61
+ when :balanced then :package_registries
62
+ else :custom
63
+ end
64
+ end
65
+
66
+ # @return [Hash{Symbol=>Boolean}] security control id => whether a missing
67
+ # control blocks advancement under this posture
68
+ def obligations = OBLIGATIONS.fetch(name)
69
+
70
+ # @return [Array<Symbol>] controls whose absence blocks
71
+ def required_controls = obligations.select { |_, required| required }.keys
72
+
73
+ # @return [Array<Symbol>] controls reported but not blocking
74
+ def advisory_controls = obligations.reject { |_, required| required }.keys
75
+
76
+ def to_sym = name
77
+
78
+ def to_s = name.to_s
79
+ end
80
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Reanimator
6
+ # Where a repository's recorded contract snapshot lives - and the only place
7
+ # Reanimator ever writes one (specification section 06).
8
+ #
9
+ # There are two conventions, because drift has two shapes. A repository you
10
+ # own records its snapshot *inside itself*, as a committed file beside
11
+ # `.reanimator.yml`: the contract and the record of what it last required
12
+ # are the same kind of thing, and keeping both under review means a policy
13
+ # change arrives as a diff a human reads rather than as state hidden in a
14
+ # tool. A portfolio of repositories you cannot write into records them side
15
+ # by side in one directory instead, keyed by repository.
16
+ class SnapshotStore
17
+ DEFAULT_FILENAME = ".reanimator-snapshot.json"
18
+
19
+ # @return [String, nil] the portfolio-side directory, or nil when each
20
+ # repository keeps its own committed snapshot
21
+ attr_reader :directory
22
+
23
+ def initialize(directory: nil)
24
+ @directory = directory
25
+ end
26
+
27
+ # @param repository [Repository]
28
+ # @return [String] where this repository's snapshot is read from and written to
29
+ def path_for(repository)
30
+ return File.join(repository.path, DEFAULT_FILENAME) unless directory
31
+
32
+ File.join(directory, "#{key_for(repository)}.json")
33
+ end
34
+
35
+ def recorded?(repository) = File.file?(path_for(repository))
36
+
37
+ # @param repository [Repository]
38
+ # @return [ContractSnapshot, nil] nil when nothing has been recorded yet
39
+ def load(repository)
40
+ recorded?(repository) ? ContractSnapshot.load(path_for(repository)) : nil
41
+ end
42
+
43
+ # @param snapshot [ContractSnapshot]
44
+ # @param repository [Repository]
45
+ # @return [String] the path written
46
+ def save(snapshot, repository)
47
+ destination = path_for(repository)
48
+ FileUtils.mkdir_p(directory) if directory
49
+ File.write(destination, snapshot.to_json)
50
+ destination
51
+ rescue SystemCallError => e
52
+ raise ContractError, "Could not write the contract snapshot to #{destination}: #{e.message}"
53
+ end
54
+
55
+ # The exact command a maintainer runs to re-record a snapshot, for a report
56
+ # to quote. The store is the one place that knows where snapshots live, so
57
+ # it is also the one place that knows how to say "put it back".
58
+ #
59
+ # @param file [String, nil] an explicitly named snapshot file, if the
60
+ # maintainer bypassed the store with one
61
+ def record_command(file = nil)
62
+ return "reanimator drift --save #{file}" if file
63
+ return "reanimator drift --record --snapshots #{directory}" if directory
64
+
65
+ "reanimator drift --record"
66
+ end
67
+
68
+ private
69
+
70
+ # A repository's identity flattened into one filename. A collision is not a
71
+ # correctness risk: {DriftDetector} refuses to compare snapshots from two
72
+ # different repositories, so a mis-keyed file fails closed instead of
73
+ # reporting the most dangerous possible answer - "no drift".
74
+ def key_for(repository)
75
+ repository.full_name.to_s.gsub(/[^A-Za-z0-9._-]+/, "-")
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A category of maintenance work with a default autonomy posture per rung
5
+ # (specification section 11, "Default autonomy by task class"). The table is
6
+ # deliberately conservative: security remediation may repair and submit, but
7
+ # major upgrades only plan, verifier changes demand elevated review, and
8
+ # nothing merges automatically.
9
+ class TaskClass
10
+ # Per-rung default policy tokens used in the section 11 table.
11
+ # :auto run automatically
12
+ # :allowed permitted when the rung is enabled
13
+ # :draft permitted, but only as a draft PR
14
+ # :draft_elevated draft PR plus elevated review
15
+ # :plan_only may only produce a plan
16
+ # :issue may only open an issue
17
+ # :optional permitted but not default
18
+ # :by_exception only when explicitly excepted
19
+ # :off not permitted
20
+ attr_reader :key, :title, :defaults, :elevated_review
21
+
22
+ def initialize(key:, title:, defaults:, elevated_review: false)
23
+ @key = key
24
+ @title = title
25
+ @defaults = defaults
26
+ @elevated_review = elevated_review
27
+ freeze
28
+ end
29
+
30
+ def elevated_review? = @elevated_review
31
+
32
+ def default_for(level) = defaults.fetch(level.to_sym, :off)
33
+
34
+ def self.fetch(key)
35
+ ALL.fetch(key.to_sym) { raise UnknownError, "Unknown task class #{key.inspect}. Known: #{ALL.keys.join(", ")}." }
36
+ end
37
+
38
+ def self.keys = ALL.keys
39
+
40
+ def self.build(key, title, observe:, diagnose:, repair:, submit:, merge:, elevated_review: false)
41
+ new(key: key, title: title, elevated_review: elevated_review,
42
+ defaults: {observe: observe, diagnose: diagnose, repair: repair, submit: submit, merge: merge})
43
+ end
44
+
45
+ ALL = {
46
+ critical_security: build(:critical_security, "Critical security remediation",
47
+ observe: :auto, diagnose: :auto, repair: :allowed, submit: :allowed, merge: :off),
48
+ patch_dependency: build(:patch_dependency, "Patch dependency update",
49
+ observe: :auto, diagnose: :auto, repair: :allowed, submit: :allowed, merge: :off),
50
+ minor_dependency: build(:minor_dependency, "Minor dependency update",
51
+ observe: :auto, diagnose: :auto, repair: :allowed, submit: :draft, merge: :off),
52
+ major_upgrade: build(:major_upgrade, "Major dependency or runtime update",
53
+ observe: :auto, diagnose: :auto, repair: :plan_only, submit: :issue, merge: :off),
54
+ mechanical_formatting: build(:mechanical_formatting, "Mechanical formatting",
55
+ observe: :auto, diagnose: :auto, repair: :optional, submit: :draft, merge: :off),
56
+ behavioral_refactor: build(:behavioral_refactor, "Behavioral refactor",
57
+ observe: :auto, diagnose: :auto, repair: :by_exception, submit: :draft, merge: :off),
58
+ verifier_modification: build(:verifier_modification, "CI or verifier modification",
59
+ observe: :auto, diagnose: :auto, repair: :by_exception, submit: :draft_elevated, merge: :off,
60
+ elevated_review: true)
61
+ }.freeze
62
+ end
63
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A reusable bundle of inferred or declared obligations for a common project
5
+ # shape - ruby-gem, rails-app, ruby-cli (specification section 02). A
6
+ # profile says what "healthy" *usually* looks like; it removes repetitive
7
+ # configuration without hiding the resulting contract. Profiles compose:
8
+ # rails-app inherits from web-application, and both resolve against the
9
+ # detected evidence of a specific repository.
10
+ class VitalityProfile
11
+ # @return [Symbol] stable profile id, e.g. :"ruby-gem"
12
+ attr_reader :id
13
+ # @return [String] one-line description
14
+ attr_reader :summary
15
+ # @return [Array<Symbol>] profiles this one inherits obligations from
16
+ attr_reader :inherits
17
+ # @return [Array<AcceptanceCriterion>] criteria contributed by this profile
18
+ attr_reader :criteria
19
+
20
+ def initialize(id:, summary:, inherits: [], criteria: [])
21
+ @id = id.to_sym
22
+ @summary = summary
23
+ @inherits = Array(inherits).map(&:to_sym)
24
+ @criteria = criteria
25
+ freeze
26
+ end
27
+
28
+ # The fully-resolved criteria for this profile, including inherited ones,
29
+ # de-duplicated by id with the most specific (this profile) winning.
30
+ #
31
+ # @param registry [#fetch] profile lookup, defaults to {Profiles}
32
+ # @return [Array<AcceptanceCriterion>]
33
+ def resolved_criteria(registry: Profiles)
34
+ inherited = inherits.flat_map { |parent| registry.fetch(parent).resolved_criteria(registry: registry) }
35
+ by_id = {}
36
+ (inherited + criteria).each { |c| by_id[c.id] = c }
37
+ by_id.values
38
+ end
39
+
40
+ def to_h
41
+ {id: id, summary: summary, inherits: inherits}
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Reanimator
7
+ # An explicit, scoped, attributable exception to an obligation (specification
8
+ # section 09). A waiver does not make a failing criterion pass - "a waived
9
+ # obligation is not counted as verified; it is counted as accepted risk". It
10
+ # is time-bounded when appropriate: past its expiry it stops applying and the
11
+ # obligation is blocking again (fail closed). A waiver always carries a
12
+ # rationale, so accepted risk is never anonymous.
13
+ class Waiver
14
+ # @return [Symbol] the acceptance-criterion id this waiver accepts
15
+ attr_reader :target
16
+ # @return [String] why the risk is accepted (required)
17
+ attr_reader :reason
18
+ # @return [String] who accepted it
19
+ attr_reader :owner
20
+ # @return [Hash] optional scope (environments, paths, family members)
21
+ attr_reader :scope
22
+ # @return [Time, nil] when the waiver stops applying, or nil for open-ended
23
+ attr_reader :expires
24
+
25
+ def initialize(target:, reason:, owner: "repository", scope: {}, expires: nil)
26
+ @target = target.to_sym
27
+ @reason = reason.to_s
28
+ @owner = owner
29
+ @scope = scope || {}
30
+ @expires = coerce_time(expires)
31
+ if @reason.strip.empty?
32
+ raise ContractError, "Waiver for #{target.inspect} needs a reason; accepted risk must be attributable."
33
+ end
34
+ end
35
+
36
+ # @param criterion_id [Symbol, String]
37
+ def covers?(criterion_id) = target == criterion_id.to_sym
38
+
39
+ # How this waiver reads on a result it accepts. The waiver owns its reason
40
+ # and bound, so it phrases its own acceptance rather than leaving callers
41
+ # to assemble the sentence.
42
+ #
43
+ # @param result [CriterionResult] the result being accepted
44
+ # @return [String]
45
+ def acceptance_of(result)
46
+ bound = expires ? " until #{expires.strftime("%Y-%m-%d")}" : ""
47
+ "Accepted risk#{bound}: #{reason} (was #{result.status})"
48
+ end
49
+
50
+ # Whether the waiver still applies at the given time.
51
+ def active?(now: Time.now) = expires.nil? || now < expires
52
+
53
+ def expired?(now: Time.now) = !active?(now: now)
54
+
55
+ def to_h
56
+ {target: target, reason: reason, owner: owner, expires: expires&.iso8601}.compact
57
+ end
58
+
59
+ private
60
+
61
+ # Accepts a Date or Time (from YAML) or a parseable string; nil means
62
+ # open-ended.
63
+ def coerce_time(value)
64
+ case value
65
+ when nil then nil
66
+ when Time then value
67
+ when Date then value.to_time
68
+ else Time.parse(value.to_s)
69
+ end
70
+ rescue ArgumentError
71
+ raise ContractError, "Waiver for #{target.inspect} has an invalid expires: #{value.inspect}."
72
+ end
73
+ end
74
+ end