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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ class Family
5
+ # Evaluates a family's gates (specification section 10). A candidate change
6
+ # upstream can trigger downstream checks "without opening changes in
7
+ # downstream repositories", so every check runs read-only, bounded, and in
8
+ # a disposable copy of the downstream member - never in its working tree.
9
+ #
10
+ # Execution is opt-in exactly as it is for a baseline: by default the runner
11
+ # reports what it *would* prove, so the graph is useful before anyone grants
12
+ # the right to run anything.
13
+ class GateRunner
14
+ # A downstream member proves itself with its smoke script when it has one
15
+ # (the section 10 example uses bin/smoke), else its discovered tests.
16
+ SMOKE_SCRIPTS = ["bin/smoke", "bin/doctor", "script/smoke"].freeze
17
+
18
+ attr_reader :family, :execute, :timeout
19
+
20
+ def initialize(family, execute: false, timeout: nil)
21
+ @family = family
22
+ @execute = execute
23
+ @timeout = timeout || Reanimator.configuration.command_timeout
24
+ end
25
+
26
+ # @return [Array<GateResult>]
27
+ def run
28
+ family.gates.map { |gate| evaluate(gate) }
29
+ end
30
+
31
+ private
32
+
33
+ def evaluate(gate)
34
+ member = family.member(gate.requires_repository)
35
+ return missing(gate, "#{gate.requires_repository} is not a member of family #{family.id}.") unless member
36
+ return missing(gate, "No local checkout known for #{gate.requires_repository}.") unless member.path
37
+
38
+ check = gate.check || discover_check(member)
39
+ return missing(gate, "No proof command found in #{gate.requires_repository}; the edge is unverifiable.") unless check
40
+
41
+ return planned(gate, check) unless execution_allowed?
42
+
43
+ run_check(gate, member, check)
44
+ end
45
+
46
+ def run_check(gate, member, check)
47
+ outcome = Isolation.copy(member.path) do |workspace|
48
+ BoundedCommand.run(check, chdir: workspace, timeout: timeout)
49
+ end
50
+
51
+ if outcome.success?
52
+ result(gate, :verified, detail: "#{check} passed in #{member.full_name}.", duration: outcome.duration)
53
+ else
54
+ reason = outcome.timed_out? ? "timed out after #{timeout}s" : "failed"
55
+ result(gate, :failed, detail: "#{check} #{reason} in #{member.full_name}.", duration: outcome.duration)
56
+ end
57
+ end
58
+
59
+ # The downstream member's own proof command: a smoke script if present,
60
+ # otherwise whatever discovery says it runs for tests.
61
+ def discover_check(member)
62
+ repository = Repository.at(member.path)
63
+ smoke = SMOKE_SCRIPTS.find { |script| repository.exist?(script) }
64
+ return smoke if smoke
65
+
66
+ Discovery.new(repository).run.discovered_commands[:tests]
67
+ rescue RepositoryError
68
+ nil
69
+ end
70
+
71
+ def execution_allowed?
72
+ execute && Reanimator.configuration.allow_execution
73
+ end
74
+
75
+ def planned(gate, check)
76
+ result(gate, :planned, detail: "Would run #{check} in #{gate.requires_repository}.")
77
+ end
78
+
79
+ def missing(gate, detail)
80
+ result(gate, :missing_evidence, detail: detail)
81
+ end
82
+
83
+ def result(gate, status, detail: nil, duration: nil)
84
+ GateResult.new(
85
+ gate: gate, status: status, detail: detail, duration: duration,
86
+ relationship: family.relationship_between(gate.on, gate.requires_repository)
87
+ )
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A graph of repositories whose health must be evaluated together
5
+ # (specification sections 02 and 10) - a gem and its executable examples, an
6
+ # application and its client, a project and its deployment samples. A family
7
+ # is the unit that answers "keep connected projects alive together": it knows
8
+ # its members and their roles, the typed edges between them, and the gates
9
+ # that make one member's proof a condition of another's.
10
+ class Family
11
+ # One repository inside a family. `path` is where it lives locally, when
12
+ # known - a gate can only run a downstream check if it can reach it.
13
+ Member = Struct.new(:full_name, :role, :path) do
14
+ def to_h = {full_name: full_name, role: role, path: path}.compact
15
+ end
16
+
17
+ attr_reader :id, :members, :gates, :relationships
18
+
19
+ def initialize(id:, members: [], gates: [], relationships: [])
20
+ @id = id.to_sym
21
+ @members = members
22
+ @gates = gates
23
+ @relationships = relationships
24
+ end
25
+
26
+ # @return [Member, nil]
27
+ def member(full_name)
28
+ members.find { |m| m.full_name == full_name }
29
+ end
30
+
31
+ def member?(full_name) = !member(full_name).nil?
32
+
33
+ def size = members.size
34
+
35
+ # Members holding a given role, e.g. :compatibility_suite.
36
+ def with_role(role)
37
+ members.select { |m| m.role == role.to_sym }
38
+ end
39
+
40
+ # The edge between two members, used to name exactly what broke.
41
+ def relationship_between(upstream, downstream)
42
+ relationships.find { |r| r.to == upstream && r.from == downstream }
43
+ end
44
+
45
+ # Parses the section 10 family-policy shape:
46
+ #
47
+ # family:
48
+ # id: agentic
49
+ # members:
50
+ # codenamev/agentic: {role: library}
51
+ # codenamev/agentic-examples: {role: compatibility-suite}
52
+ # gates:
53
+ # - on: codenamev/agentic
54
+ # require: {repository: codenamev/agentic-examples, check: bin/smoke}
55
+ #
56
+ # @param declaration [Hash]
57
+ # @param paths [Hash{String=>String}] optional full_name => local path
58
+ # @return [Family]
59
+ def self.from_declaration(declaration, paths: {})
60
+ raise ContractError, "A family declaration needs an id." unless declaration[:id]
61
+
62
+ members = (declaration[:members] || {}).map do |full_name, attributes|
63
+ name = full_name.to_s
64
+ role = (attributes || {})[:role] || :member
65
+ Member.new(name, role.to_s.tr("-", "_").to_sym, paths[name])
66
+ end
67
+
68
+ gates = (declaration[:gates] || []).map do |gate|
69
+ required = gate[:require] || {}
70
+ Gate.new(
71
+ on: gate[:on].to_s,
72
+ requires_repository: required[:repository].to_s,
73
+ check: required[:check]
74
+ )
75
+ end
76
+
77
+ new(id: declaration[:id], members: members, gates: gates)
78
+ end
79
+
80
+ def to_h
81
+ {
82
+ id: id,
83
+ members: members.map(&:to_h),
84
+ relationships: relationships.map(&:to_h),
85
+ gates: gates.map(&:to_h)
86
+ }
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Infers repository families from a portfolio (specification section 10).
5
+ # The strongest signals are structural and cheap: a repository named
6
+ # `<project>-examples` is that project's compatibility suite,
7
+ # `<project>-example-heroku` is a deployment example, `<project>-docs` is its
8
+ # documentation, and `<host>-<project>` is a plugin for a project that is
9
+ # itself in the portfolio.
10
+ #
11
+ # Inference is deliberately conservative - it only proposes an edge it can
12
+ # point at concrete evidence for. An owner-declared family always wins, and
13
+ # weaker "these names look related" hunches are left to declaration rather
14
+ # than turned into obligations.
15
+ module FamilyInference
16
+ # Each rule maps a repository name to the project it depends on, and the
17
+ # kind of dependence that implies.
18
+ SUFFIX_RULES = [
19
+ [/\A(?<base>.+?)[-_]examples?\z/, :compatibility_suite],
20
+ [/\A(?<base>.+?)[-_]example[-_].+\z/, :deployment_example],
21
+ [/\A(?<base>.+?)[-_]docs?\z/, :documentation_for]
22
+ ].freeze
23
+
24
+ module_function
25
+
26
+ # @param portfolio [Portfolio]
27
+ # @return [Array<Family>] families with at least one edge, id-sorted
28
+ def call(portfolio)
29
+ repositories = portfolio.entries.map(&:repository)
30
+ declared = declared_families(repositories)
31
+ inferred = inferred_families(repositories, declared)
32
+
33
+ (declared.values + inferred.values).sort_by { |family| family.id.to_s }
34
+ end
35
+
36
+ # Families a repository explicitly joined via `family: {id:, role:}`.
37
+ def declared_families(repositories)
38
+ repositories.each_with_object({}) do |repository, families|
39
+ declaration = repository.declared&.family
40
+ next unless declaration.is_a?(Hash) && declaration[:id]
41
+
42
+ id = declaration[:id].to_sym
43
+ role = (declaration[:role] || :member).to_s.tr("-", "_").to_sym
44
+ family = families[id] ||= Family.new(id: id)
45
+ family.members << Family::Member.new(repository.full_name, role, repository.path)
46
+ end
47
+ end
48
+
49
+ # Families implied by naming structure, skipping any repository that has
50
+ # already declared its membership.
51
+ def inferred_families(repositories, declared)
52
+ claimed = declared.values.flat_map { |f| f.members.map(&:full_name) }
53
+ by_name = repositories.each_with_object({}) { |r, index| index[r.name] = r }
54
+ by_full_name = repositories.each_with_object({}) { |r, index| index[r.full_name] = r }
55
+
56
+ edges = repositories.filter_map do |repository|
57
+ next if claimed.include?(repository.full_name)
58
+
59
+ relationship = relate(repository, by_name)
60
+ # Declaration wins over inference on *both* ends: once an owner has
61
+ # placed a repository in a family, we do not invent a competing family
62
+ # around it, even as the upstream of someone else's edge.
63
+ next if relationship.nil? || claimed.include?(relationship.to)
64
+
65
+ relationship
66
+ end
67
+
68
+ build_families(edges, by_full_name)
69
+ end
70
+
71
+ # The edge this repository implies, or nil when nothing matches.
72
+ def relate(repository, by_name)
73
+ SUFFIX_RULES.each do |pattern, type|
74
+ match = repository.name.match(pattern)
75
+ next unless match && by_name.key?(match[:base])
76
+
77
+ return edge(repository, by_name[match[:base]], type, "name #{repository.name} implies #{match[:base]}")
78
+ end
79
+
80
+ plugin_edge(repository, by_name)
81
+ end
82
+
83
+ # `ruby_llm-ups` is a plugin for `ups` when `ups` is itself in the portfolio.
84
+ def plugin_edge(repository, by_name)
85
+ host = repository.name.split(/[-_]/).last
86
+ return if host.nil? || host == repository.name
87
+ return unless by_name.key?(host)
88
+
89
+ edge(repository, by_name[host], :plugin_for, "name #{repository.name} targets #{host}")
90
+ end
91
+
92
+ def edge(dependent, upstream, type, evidence)
93
+ return if dependent.full_name == upstream.full_name
94
+
95
+ Relationship.new(from: dependent.full_name, to: upstream.full_name, type: type, evidence: evidence)
96
+ end
97
+
98
+ # Groups edges by their upstream project into one family each, with a gate
99
+ # per edge so every dependence has something that proves it.
100
+ def build_families(edges, by_full_name)
101
+ edges.group_by(&:to).each_with_object({}) do |(upstream_name, group), families|
102
+ upstream = by_full_name.fetch(upstream_name)
103
+
104
+ members = [Family::Member.new(upstream_name, :library, upstream.path)] +
105
+ group.map { |edge| Family::Member.new(edge.from, edge.role, by_full_name[edge.from]&.path) }
106
+ gates = group.map { |edge| Family::Gate.new(on: upstream_name, requires_repository: edge.from) }
107
+
108
+ families[upstream.name.to_sym] =
109
+ Family.new(id: upstream.name, members: members, gates: gates, relationships: group)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Reanimator
6
+ # A deliberately tiny, read-only wrapper around the git CLI. Reanimator
7
+ # never mutates a repository during observation or discovery, so this helper
8
+ # only ever runs inspection commands and returns nil rather than raising when
9
+ # git is absent or the command fails - a repository without git history is a
10
+ # fact to record, not a crash.
11
+ module Git
12
+ module_function
13
+
14
+ # @param root [String] repository working directory
15
+ # @return [Boolean] whether the path is inside a git working tree
16
+ def repository?(root)
17
+ capture(root, "rev-parse", "--is-inside-work-tree") == "true"
18
+ end
19
+
20
+ # @return [String, nil] the origin remote URL, if any
21
+ def origin_url(root)
22
+ capture(root, "config", "--get", "remote.origin.url")
23
+ end
24
+
25
+ # @return [Boolean] whether an "upstream" remote is configured, a strong
26
+ # local hint that the repository is a maintained fork
27
+ def upstream_remote?(root)
28
+ !capture(root, "config", "--get", "remote.upstream.url").nil?
29
+ end
30
+
31
+ # @return [String, nil] the current branch name
32
+ def current_branch(root)
33
+ branch = capture(root, "rev-parse", "--abbrev-ref", "HEAD")
34
+ branch unless branch == "HEAD"
35
+ end
36
+
37
+ # @return [Time, nil] author time of the most recent commit
38
+ def last_commit_at(root)
39
+ iso = capture(root, "log", "-1", "--format=%cI")
40
+ Time.parse(iso) if iso && !iso.empty?
41
+ rescue ArgumentError
42
+ nil
43
+ end
44
+
45
+ # @return [Integer, nil] number of commits, a cheap activity signal
46
+ def commit_count(root)
47
+ count = capture(root, "rev-list", "--count", "HEAD")
48
+ Integer(count) if count
49
+ rescue ArgumentError
50
+ nil
51
+ end
52
+
53
+ # @return [Array<String>] tracked files (fast, respects .gitignore); empty
54
+ # when git is unavailable
55
+ def tracked_files(root)
56
+ out = capture(root, "ls-files")
57
+ out ? out.split("\n") : []
58
+ end
59
+
60
+ # Runs a read-only git command, returning trimmed stdout or nil on any
61
+ # failure. stderr is discarded - callers treat "no answer" as the signal.
62
+ def capture(root, *args)
63
+ require "time"
64
+ stdout, _stderr, status = Open3.capture3("git", "-C", root.to_s, *args)
65
+ return nil unless status.success?
66
+ value = stdout.strip
67
+ value.empty? ? nil : value
68
+ rescue Errno::ENOENT
69
+ nil
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+
6
+ module Reanimator
7
+ # Disposable-workspace helper (specification section 12: "disposable
8
+ # workspaces", section 14: "all commands run in a disposable workspace"). It
9
+ # copies a repository into a throwaway directory so an agent can work, and so
10
+ # evaluation can run, without ever touching the original source. The agent's
11
+ # workspace and the evaluation workspace are always distinct copies - verifier
12
+ # isolation by construction.
13
+ module Isolation
14
+ module_function
15
+
16
+ # Copies a repository root into a fresh temp directory (excluding .git),
17
+ # yielding the copy to the block and removing it afterward. Without a block
18
+ # it returns the path and the caller owns cleanup.
19
+ def copy(root)
20
+ destination = Dir.mktmpdir("reanimator-iso")
21
+ Dir.children(root).each do |child|
22
+ next if child == ".git"
23
+
24
+ FileUtils.cp_r(File.join(root, child), File.join(destination, child))
25
+ end
26
+
27
+ return destination unless block_given?
28
+
29
+ begin
30
+ yield destination
31
+ ensure
32
+ FileUtils.remove_entry(destination, true)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A cheap primary-language guess from file extensions. This is intentionally
5
+ # crude - it exists to seed cohort heuristics and reports, not to replace a
6
+ # linguist. When git is present it counts tracked files (fast and respectful
7
+ # of .gitignore); otherwise it falls back to a bounded directory walk.
8
+ module LanguageInference
9
+ EXTENSIONS = {
10
+ ".rb" => "Ruby", ".rake" => "Ruby", ".gemspec" => "Ruby",
11
+ ".js" => "JavaScript", ".mjs" => "JavaScript", ".ts" => "TypeScript",
12
+ ".py" => "Python", ".go" => "Go", ".rs" => "Rust",
13
+ ".java" => "Java", ".kt" => "Kotlin", ".c" => "C", ".h" => "C",
14
+ ".cpp" => "C++", ".cs" => "C#", ".php" => "PHP",
15
+ ".ex" => "Elixir", ".exs" => "Elixir", ".erl" => "Erlang",
16
+ ".swift" => "Swift", ".sh" => "Shell", ".crystal" => "Crystal", ".cr" => "Crystal"
17
+ }.freeze
18
+
19
+ MAX_WALK_FILES = 5_000
20
+
21
+ module_function
22
+
23
+ # @param root [String]
24
+ # @return [String, nil] the most common recognized language, or nil
25
+ def call(root)
26
+ counts = Hash.new(0)
27
+ files(root).each do |rel|
28
+ language = EXTENSIONS[File.extname(rel)]
29
+ counts[language] += 1 if language
30
+ end
31
+ counts.max_by { |_lang, n| n }&.first
32
+ end
33
+
34
+ def files(root)
35
+ tracked = Git.tracked_files(root)
36
+ return tracked unless tracked.empty?
37
+
38
+ Dir.glob("**/*", base: root, flags: File::FNM_DOTMATCH)
39
+ .reject { |rel| rel.start_with?(".git/") }
40
+ .first(MAX_WALK_FILES)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # The first and most important configuration value (specification section
5
+ # 05). Lifecycle intent is the owner's declaration of *why* a repository
6
+ # should continue to exist and what level of care it deserves. The same
7
+ # technical signal - say, a failing test - means different things for a
8
+ # maintained product, a preserved artifact, and an ignored fork, so intent
9
+ # is never inferred as final without confirmation.
10
+ class LifecycleIntent
11
+ # @return [Symbol] stable key used in contracts and reports
12
+ attr_reader :key
13
+ # @return [String] the promise Reanimator makes for this intent
14
+ attr_reader :promise
15
+ # @return [String] the default activity this intent authorizes
16
+ attr_reader :default_activity
17
+ # @return [Boolean] whether the intent creates any stewardship obligation
18
+ attr_reader :creates_obligation
19
+
20
+ def initialize(key:, promise:, default_activity:, creates_obligation: true)
21
+ @key = key
22
+ @promise = promise
23
+ @default_activity = default_activity
24
+ @creates_obligation = creates_obligation
25
+ freeze
26
+ end
27
+
28
+ # @return [Boolean] whether the intent creates any stewardship obligation
29
+ def creates_obligation?
30
+ @creates_obligation
31
+ end
32
+
33
+ # The canonical intents, keyed by symbol. Order is meaningful for display:
34
+ # most-active first, dormant last.
35
+ ALL = {
36
+ maintain: new(
37
+ key: :maintain,
38
+ promise: "Keep the repository runnable, supported, secure, and within its declared contract.",
39
+ default_activity: "Scheduled verification and eligible repair."
40
+ ),
41
+ monitor: new(
42
+ key: :monitor,
43
+ promise: "Detect meaningful deterioration without routine code churn.",
44
+ default_activity: "Periodic checks and event-triggered diagnosis."
45
+ ),
46
+ preserve: new(
47
+ key: :preserve,
48
+ promise: "Protect historical readability, provenance, and known reproduction instructions.",
49
+ default_activity: "Link, license, metadata, and optional reproducibility checks."
50
+ ),
51
+ incubate: new(
52
+ key: :incubate,
53
+ promise: "Surface missing foundations while the project is still becoming real.",
54
+ default_activity: "Report gaps; avoid unsolicited modernization."
55
+ ),
56
+ retire: new(
57
+ key: :retire,
58
+ promise: "Make end-of-life explicit and safely reduce obligations.",
59
+ default_activity: "Archive checklist, notices, migration path, and access cleanup."
60
+ ),
61
+ upstream: new(
62
+ key: :upstream,
63
+ promise: "Track a fork's divergence and upstream movement.",
64
+ default_activity: "Compare ancestry and unique commits; no generic dependency churn."
65
+ ),
66
+ ignore: new(
67
+ key: :ignore,
68
+ promise: "Create no stewardship obligation.",
69
+ default_activity: "No scheduled activity.",
70
+ creates_obligation: false
71
+ )
72
+ }.freeze
73
+
74
+ # @param key [Symbol, String]
75
+ # @return [LifecycleIntent]
76
+ # @raise [UnknownError] when the key is not a canonical intent
77
+ def self.fetch(key)
78
+ ALL.fetch(key.to_sym) do
79
+ raise UnknownError, "Unknown lifecycle intent #{key.inspect}. Known: #{ALL.keys.join(", ")}."
80
+ end
81
+ end
82
+
83
+ # @param key [Symbol, String]
84
+ # @return [Boolean]
85
+ def self.valid?(key)
86
+ ALL.key?(key.to_sym)
87
+ end
88
+
89
+ def to_sym = key
90
+
91
+ def to_s = key.to_s
92
+ end
93
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # A bounded unit of drift or risk that must be verified, repaired, accepted,
5
+ # deferred, or waived (specification section 02 and section 17). An obligation
6
+ # is idempotent and deduplicated by repository, policy, target, and drift
7
+ # condition, so the same failing criterion does not spawn a swarm of tasks.
8
+ class Obligation
9
+ STATES = %i[open in_progress resolved deferred waived].freeze
10
+
11
+ attr_reader :repository, :policy_version, :target, :trigger, :urgency, :task_class, :state
12
+
13
+ def initialize(repository:, policy_version:, target:, trigger:, task_class:, urgency: :normal, state: :open)
14
+ @repository = repository
15
+ @policy_version = policy_version
16
+ @target = target
17
+ @trigger = trigger
18
+ @task_class = task_class
19
+ @urgency = urgency
20
+ @state = state
21
+ end
22
+
23
+ # Stable identity used for idempotent deduplication (section 17).
24
+ def id
25
+ "#{repository.full_name}::#{policy_version}::#{target.id}"
26
+ end
27
+
28
+ def to_h
29
+ {
30
+ id: id, repository: repository.full_name, policy_version: policy_version,
31
+ target: target.id, trigger: trigger, task_class: task_class, urgency: urgency, state: state
32
+ }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reanimator
4
+ # Resolves what an agent is actually permitted to do for a task, by
5
+ # intersecting the task class defaults (section 11) with the repository's
6
+ # enabled autonomy (the effective contract) and the safety floor. The contract
7
+ # is a *ceiling*: a task class may default to "submit as draft", but if the
8
+ # repository has not enabled submit, the resolved action is off. Merge is
9
+ # always off in v1.
10
+ class PermissionModel
11
+ # The resolved decision for one task against one contract.
12
+ Resolution = Struct.new(:task_class, :actions, :elevated_review, :max_action) do
13
+ def elevated_review? = elevated_review
14
+
15
+ def permits?(level) = actions.fetch(level.to_sym, :off) != :off
16
+ end
17
+
18
+ def initialize(contract)
19
+ @contract = contract
20
+ end
21
+
22
+ # @param task_class [Symbol, TaskClass]
23
+ # @return [Resolution]
24
+ def resolve(task_class)
25
+ klass = task_class.is_a?(TaskClass) ? task_class : TaskClass.fetch(task_class)
26
+ actions = AutonomyLevel.keys.each_with_object({}) do |level, resolved|
27
+ resolved[level] = clamp(level, klass.default_for(level))
28
+ end
29
+ actions[:merge] = :off # safety floor: no automatic merge in v1
30
+
31
+ Resolution.new(
32
+ task_class: klass.key,
33
+ actions: actions,
34
+ elevated_review: klass.elevated_review? || touches_verifier?,
35
+ max_action: highest_enabled(actions)
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ # Clamp a task-class default against what the contract enables for the rung.
42
+ def clamp(level, default)
43
+ return default if %i[observe diagnose].include?(level) # always at least observe/diagnose
44
+
45
+ ceiling = @contract.autonomy[level]
46
+ disabled?(ceiling) ? :off : default
47
+ end
48
+
49
+ def disabled?(ceiling)
50
+ ceiling.nil? || ceiling.to_sym == :disabled
51
+ end
52
+
53
+ def touches_verifier?
54
+ @contract.verifier_criteria.any?
55
+ end
56
+
57
+ def highest_enabled(actions)
58
+ AutonomyLevel.keys.reverse.find { |level| actions[level] != :off } || :observe
59
+ end
60
+ end
61
+ end