samagotchi 0.157.0 → 0.158.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +27 -1
  3. data/README.md +1 -1
  4. data/bin/chi +3 -1
  5. data/docs/architecture.md +7 -6
  6. data/docs/bundle-authoring.md +5 -5
  7. data/docs/bundles.md +2 -1
  8. data/docs/cli.md +21 -17
  9. data/docs/configuration.md +7 -5
  10. data/docs/context.md +5 -3
  11. data/docs/guardrails.md +8 -7
  12. data/docs/hooks.md +44 -80
  13. data/docs/internals/bridge.md +1 -1
  14. data/docs/internals/bundles.md +47 -34
  15. data/docs/internals/engine.md +7 -5
  16. data/docs/memory.md +39 -9
  17. data/docs/plugins.md +13 -9
  18. data/docs/releasing.md +6 -5
  19. data/docs/sessions.md +5 -5
  20. data/docs/testing.md +4 -4
  21. data/lib/samagotchi/bridge/card_places.rb +5 -13
  22. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -1
  23. data/lib/samagotchi/bridge/turn_drawings.rb +7 -1
  24. data/lib/samagotchi/bundle_command/check.rb +23 -10
  25. data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
  26. data/lib/samagotchi/bundle_command/try/run.rb +4 -1
  27. data/lib/samagotchi/bundle_command.rb +15 -43
  28. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/memory_guide.md +6 -6
  31. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  32. data/lib/samagotchi/context_command.rb +7 -5
  33. data/lib/samagotchi/context_sources.rb +4 -2
  34. data/lib/samagotchi/engine.rb +1 -6
  35. data/lib/samagotchi/extension_load.rb +20 -41
  36. data/lib/samagotchi/guardrails/load_failures.rb +3 -2
  37. data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
  38. data/lib/samagotchi/guardrails/review_paths.rb +1 -5
  39. data/lib/samagotchi/hook_routes.rb +2 -2
  40. data/lib/samagotchi/hooks.rb +2 -3
  41. data/lib/samagotchi/memory_bundle/asset_installer.rb +39 -119
  42. data/lib/samagotchi/memory_bundle/builder.rb +10 -34
  43. data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
  44. data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
  45. data/lib/samagotchi/memory_bundle/installed_copy.rb +11 -18
  46. data/lib/samagotchi/memory_bundle/installed_manifest.rb +2 -16
  47. data/lib/samagotchi/memory_bundle/installer.rb +32 -14
  48. data/lib/samagotchi/memory_bundle/manifest.rb +37 -54
  49. data/lib/samagotchi/memory_bundle/provenance.rb +11 -51
  50. data/lib/samagotchi/memory_bundle/remerge.rb +7 -3
  51. data/lib/samagotchi/memory_bundle/shas.rb +5 -8
  52. data/lib/samagotchi/memory_bundle/shipped_update.rb +30 -35
  53. data/lib/samagotchi/memory_bundle/status.rb +12 -10
  54. data/lib/samagotchi/memory_bundle/system_bundle.rb +16 -5
  55. data/lib/samagotchi/memory_bundle/trash.rb +7 -12
  56. data/lib/samagotchi/memory_orphans.rb +4 -4
  57. data/lib/samagotchi/memory_paths.rb +102 -13
  58. data/lib/samagotchi/plugin/api.rb +1 -4
  59. data/lib/samagotchi/plugin/loader.rb +42 -5
  60. data/lib/samagotchi/project_command.rb +167 -0
  61. data/lib/samagotchi/project_init.rb +69 -0
  62. data/lib/samagotchi/project_scope.rb +6 -4
  63. data/lib/samagotchi/prompt_files.rb +10 -7
  64. data/lib/samagotchi/recap_place.rb +3 -4
  65. data/lib/samagotchi/run_options.rb +1 -0
  66. data/lib/samagotchi/session.rb +44 -18
  67. data/lib/samagotchi/session_access/contract.rb +1 -1
  68. data/lib/samagotchi/session_field.rb +5 -4
  69. data/lib/samagotchi/steer.rb +10 -0
  70. data/lib/samagotchi/system_prompt.rb +20 -11
  71. data/lib/samagotchi/terminal_ui/join_replay.rb +6 -1
  72. data/lib/samagotchi/testing/bundle.rb +5 -12
  73. data/lib/samagotchi/testing/install.rb +5 -5
  74. data/lib/samagotchi/testing/recorder.rb +1 -1
  75. data/lib/samagotchi/testing.rb +1 -1
  76. data/lib/samagotchi/tool_declarations.rb +1 -1
  77. data/lib/samagotchi/toolkit_binding.rb +1 -4
  78. data/lib/samagotchi/toolkit_command.rb +1 -4
  79. data/lib/samagotchi/tools/delegate.rb +12 -8
  80. data/lib/samagotchi/version.rb +1 -1
  81. data/lib/samagotchi/web/app.rb +35 -0
  82. data/lib/samagotchi/web/project_list.rb +3 -2
  83. data/lib/samagotchi/web/public/index.html +6 -1
  84. data/lib/samagotchi/web/public/project_picker.js +86 -4
  85. metadata +3 -2
  86. data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
@@ -27,8 +27,8 @@ module Samagotchi
27
27
  # bundle that isn't installed and never downgrades.
28
28
  #
29
29
  # .md files take the Installer's 3-way merge: a clean text merge replaces
30
- # the file (merged), a real conflict keeps the user's (behind). Hooks, rules,
31
- # the plugin and scripts are replaced; an edited one (Trash.edit?: its
30
+ # the file (merged), a real conflict keeps the user's (behind). Rules,
31
+ # the plugin and scripts are replaced, an older chi's hooks removed; an edited one (Trash.edit?: its
32
32
  # bytes neither the recorded sha, none recorded counting as edited, nor
33
33
  # the incoming file's) is copied to the trash first and listed in
34
34
  # replaced (#trash_bound).
@@ -119,10 +119,7 @@ module Samagotchi
119
119
  r.merged = installer.merged_files
120
120
  r.behind = installer.behind
121
121
  r.trash_copy = installer.trash_copy_line
122
- # The integrity check's warnings; the rest of the Installer's say
123
- # what the row's merged and behind already do.
124
- mismatches = installer.warnings.grep(/\AChecksum mismatch/)
125
- r.note = mismatches.join("; ") unless mismatches.empty?
122
+ r.note = mismatch_note(installer)
126
123
  end
127
124
  rescue StandardError => e
128
125
  row.dup.tap do |r|
@@ -202,6 +199,10 @@ module Samagotchi
202
199
  if Manifest.requires_chi_failure(manifest.requires_chi, chi_version)
203
200
  return skip(row, "needs chi #{manifest.requires_chi}", keep_to: true)
204
201
  end
202
+
203
+ # The upgrade would refuse it (Installer#refuse_hooks).
204
+ hooks = Manifest::HooksRetired.files_in(row.source_dir)
205
+ return skip(row, Manifest::HooksRetired.for_files(hooks).message, keep_to: true) unless hooks.empty?
205
206
  return skip(row, "project scope: chi bundle upgrade #{ship.source} in the project") if row.scope == "project"
206
207
  return skip(row, "unknown scope #{row.scope}: upgrade chi or reinstall") unless MemoryPaths.scope_dir(row.scope)
207
208
 
@@ -210,6 +211,7 @@ module Samagotchi
210
211
  row.behind = forecast.behind
211
212
  row.replaced = trash_bound(name, bundle, manifest, row.source_dir)
212
213
  row.status = :would_update
214
+ row.note = planned_mismatch_note(row)
213
215
  row
214
216
  rescue SystemCallError => e
215
217
  # A file it can't read: the upgrade would fail on it, so this is that
@@ -242,6 +244,20 @@ module Samagotchi
242
244
 
243
245
  def note_of(remerged) = remerged.warnings.empty? ? nil : remerged.warnings.join("; ")
244
246
 
247
+ def mismatch_note(installer)
248
+ mismatches = installer.warnings.grep(/\AChecksum mismatch/)
249
+ mismatches.join("; ") unless mismatches.empty?
250
+ end
251
+
252
+ def planned_mismatch_note(row)
253
+ installer = Installer.new(source: row.source_dir, name: row.name, scope: row.scope, strict: true, upgrade: true, dry_run: true,
254
+ shipped_dir: File.dirname(row.source_dir))
255
+ installer.run
256
+ mismatch_note(installer)
257
+ rescue Installer::InstallError
258
+ nil
259
+ end
260
+
245
261
  def profile_row(row, bundle, shipped_dir, chi_version)
246
262
  manifest = Manifest.read(dir: row.source_dir)
247
263
  newer = Listing.newer?(row.to, row.from)
@@ -277,49 +293,28 @@ module Samagotchi
277
293
  end
278
294
 
279
295
  # What the upgrade from +source_dir+ copies to the trash, as
280
- # hooks/F, guardrails/F, plugin/F, scripts/F: Trash.edit? over the files
281
- # AssetInstaller replaces or removes, each against the file it puts in
282
- # its place. Hooks: the ones install_hooks copies (listed, else every
283
- # hooks/*.rb; present, not links) and the ones prune_dropped_hooks
284
- # removes (recorded, no longer shipped; no incoming). Rules, the plugin
285
- # and scripts: their whole folder, which the upgrade replaces.
296
+ # hooks/F, guardrails/F, plugin/F, scripts/F: Trash.edits over the
297
+ # folders AssetInstaller replaces or removes, each file against the one
298
+ # it puts in its place. Rules, the plugin and scripts: their whole
299
+ # folder, which the upgrade replaces. hooks/: an older chi's, which the
300
+ # upgrade removes whole (AssetInstaller#retire_hooks; no incoming).
286
301
  def trash_bound(name, bundle, manifest, source_dir)
287
302
  provenance = Provenance.new(name: name)
288
303
  rules = Dir.glob(File.join(source_dir, "guardrails", "*.yml")).sort
289
304
  .reject { |file| AssetInstaller.linked?(file, source_dir) }
290
305
  plugin = manifest.plugin ? { manifest.plugin[:file] => File.join(source_dir, manifest.plugin[:file]) } : {}
291
306
  scripts = manifest.scripts.keys.to_h { |file| [file, File.join(source_dir, "scripts", file)] }
292
- trash_bound_hooks(provenance, bundle, manifest, source_dir) +
307
+ Trash.edits(provenance.hooks_dir, "hooks", bundle.hooks.transform_values(&:sha256)) +
293
308
  Trash.edits(provenance.guardrails_dir, "guardrails", bundle.guardrails, rules.to_h { |file| [File.basename(file), file] }) +
294
309
  Trash.edits(provenance.plugin_dir, "plugin", bundle.plugin_shas, plugin) +
295
310
  Trash.edits(provenance.scripts_dir, "scripts", bundle.scripts, scripts)
296
311
  end
297
312
 
298
- # #trash_bound's hooks: AssetInstaller#install_hooks' set (incoming: the
299
- # source's file), then #prune_dropped_hooks' (incoming: none). A stray
300
- # file in hooks/ is in neither: the upgrade leaves it.
301
- def trash_bound_hooks(provenance, bundle, manifest, source_dir)
302
- src = ->(file) { File.join(source_dir, "hooks", file) }
303
- listed = manifest.hooks.keys.select { |file| file.is_a?(String) && !file.empty? }
304
- listed = Dir.glob(src.call("*.rb")).map { |file| File.basename(file) } if manifest.hooks.empty?
305
- shipped = listed.reject do |file|
306
- (File.symlink?(src.call(file)) || File.exist?(src.call(file))) && AssetInstaller.linked?(src.call(file), source_dir)
307
- end
308
- incoming = shipped.select { |file| File.exist?(src.call(file)) }.to_h { |file| [file, src.call(file)] }
309
- incoming.merge!((bundle.hooks.keys - shipped).to_h { |file| [file, nil] })
310
- incoming.filter_map do |file, from|
311
- "hooks/#{file}" if Trash.edit?(File.join(provenance.hooks_dir, file), recorded: bundle.hooks[file]&.sha256, incoming: from)
312
- end
313
- end
314
-
315
- # Installed hooks, rules and the plugin whose sha differs from the
316
- # recorded one (they didn't load), as hooks/F, guardrails/F, plugin/F.
313
+ # Installed rules and the plugin whose sha differs from the recorded
314
+ # one (they didn't load), as guardrails/F, plugin/F.
317
315
  def edited_executables(name, bundle)
318
316
  provenance = Provenance.new(name: name)
319
317
  edited = []
320
- bundle.hooks.each do |file, hook|
321
- edited << "hooks/#{file}" if edited?(File.join(provenance.hooks_dir, file), hook.sha256)
322
- end
323
318
  bundle.guardrails.each do |file, sha|
324
319
  edited << "guardrails/#{file}" if edited?(File.join(provenance.guardrails_dir, file), sha)
325
320
  end
@@ -16,14 +16,15 @@ module Samagotchi
16
16
  # A bundle as this chi reads it now (what chi bundle status prints):
17
17
  # its stored record, the scope it resolves to (scope_error when it
18
18
  # can't), the folder its files belong in, what each file looks like on
19
- # disk, its installed plugin, why none of its hooks load, and its needs
19
+ # disk, its installed plugin, the class-file hooks its record still
20
+ # has (retired_hooks: they don't load), and its needs
20
21
  # with what this process's PATH found. The +files+ entries and +plugin+
21
22
  # stay Hashes: their fields differ per case (an unchecked file has no
22
23
  # paths at all). An entry's behind is its FileStanding::Behind, nil for a
23
24
  # file that isn't; merge_note its FileStanding::MergeNote, nil without one;
24
25
  # unreadable: the file is there, but this chi can't read it.
25
26
  Report = Data.define(:provenance, :scope, :scope_error, :target_dir, :files, :plugin,
26
- :hooks_requires_failure, :needs)
27
+ :retired_hooks, :needs)
27
28
 
28
29
  # A scope this chi can't resolve (a newer chi's, a hand edit) isn't
29
30
  # guessed: scope_error says so, target_dir is nil and each file is
@@ -69,7 +70,7 @@ module Samagotchi
69
70
  end
70
71
  Report.new(provenance: bundle, scope: scope, scope_error: target_dir ? nil : "unknown scope: #{scope}",
71
72
  target_dir: target_dir, files: details, plugin: plugin_status(provenance, bundle),
72
- hooks_requires_failure: hooks_requires_failure(bundle), needs: needs_status(bundle))
73
+ retired_hooks: retired_hooks(provenance, bundle), needs: needs_status(bundle))
73
74
  end
74
75
 
75
76
  # A FileState digest as the Report has it: nil for none, or one that
@@ -77,13 +78,14 @@ module Samagotchi
77
78
  def self.digest_or_nil(digest) = digest == :unreadable ? nil : digest
78
79
  private_class_method :digest_or_nil
79
80
 
80
- # Why none of the bundle's hooks load (this chi doesn't meet its
81
- # requires_chi, Hooks::BundleLoader), or nil: it has no hooks or meets it.
82
- def self.hooks_requires_failure(bundle)
83
- manifest = bundle.manifest
84
- return nil if manifest.hooks.empty?
85
-
86
- manifest.requires_chi_failure
81
+ # The class-file hooks the record still has (a 0.157 install's; chi
82
+ # loads none, Manifest::HooksRetired), each "ok" or "missing" in
83
+ # hooks/, by name; {} for none.
84
+ # @return [Hash{String => String}]
85
+ def self.retired_hooks(provenance, bundle)
86
+ bundle.hooks.keys.sort.to_h do |file|
87
+ [file, File.exist?(File.join(provenance.hooks_dir, file)) ? "ok" : "missing"]
88
+ end
87
89
  end
88
90
 
89
91
  # The stored needs, each with found: from this process's PATH (the
@@ -214,7 +214,11 @@ module Samagotchi
214
214
  missing = MemoryFiles.select(GEM_BUNDLE_DIR, gem_manifest).reject { |key| File.exist?(File.join(target_dir, key)) }
215
215
  missing = restored_rows(missing, target_dir) unless missing.empty?
216
216
  return result(:up_to_date, from: version, to: version) if missing.empty?
217
- return result(:restored, from: version, to: version) if dry_run
217
+
218
+ if dry_run
219
+ warnings = missing.filter_map { |key| restore_mismatch(key, gem_manifest) }
220
+ return result(:restored, from: version, to: version, warnings: warnings)
221
+ end
218
222
 
219
223
  warnings = missing.flat_map { |key| restore_file(key, gem_manifest) }
220
224
  result(:restored, from: version, to: version, warnings: warnings)
@@ -238,7 +242,7 @@ module Samagotchi
238
242
  description = gem_manifest.file_descriptions[key]
239
243
  FileUtils.cp(source, target)
240
244
  Provenance.new(name: BUNDLE_NAME).record_restored(key, source, description: description)
241
- warnings = [FileDigest.mismatch(key, target, gem_manifest.checksum_for(key))].compact
245
+ warnings = [restore_mismatch(key, gem_manifest)].compact
242
246
  begin
243
247
  IndexUpdater.update_index(SCOPE, key.delete_suffix(".md"), File.size(target), description, source: BUNDLE_NAME)
244
248
  rescue StandardError => e
@@ -247,6 +251,10 @@ module Samagotchi
247
251
  warnings
248
252
  end
249
253
 
254
+ def restore_mismatch(key, gem_manifest)
255
+ FileDigest.mismatch(key, File.join(GEM_BUNDLE_DIR, key), gem_manifest.checksum_for(key))
256
+ end
257
+
250
258
  # An up-to-date bundle's marked files, retried. No marks: nothing is read.
251
259
  def remerge(bundle, verified, dry_run)
252
260
  done = Remerge.new(name: BUNDLE_NAME, scope: SCOPE, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR, record: bundle)
@@ -264,7 +272,10 @@ module Samagotchi
264
272
  owned = provenance.record&.files&.keys || []
265
273
  forecast = Merger::Upgrade.new(provenance: provenance, target_dir: MemoryPaths.system_dir, source_dir: GEM_BUNDLE_DIR)
266
274
  .forecast(gem_manifest.files.keys.map(&:to_s).sort & owned, version: to)
267
- result(:updated, from: from, to: to, merged: forecast.merged, behind: forecast.behind)
275
+ installer = upgrade_installer(dry_run: true)
276
+ installer.run
277
+ warnings = installer.warnings.grep(/\AChecksum mismatch/)
278
+ result(:updated, from: from, to: to, merged: forecast.merged, behind: forecast.behind, warnings: warnings)
268
279
  end
269
280
 
270
281
  # Provenance#adopt_manifest from the shipped dir, for a record without a
@@ -276,9 +287,9 @@ module Samagotchi
276
287
  Provenance.new(name: BUNDLE_NAME).adopt_manifest(GEM_BUNDLE_DIR)
277
288
  end
278
289
 
279
- def upgrade_installer
290
+ def upgrade_installer(dry_run: false)
280
291
  Installer.new(source: GEM_BUNDLE_DIR, name: BUNDLE_NAME, scope: SCOPE, force: false, strict: true, upgrade: true,
281
- shipped_dir: File.dirname(GEM_BUNDLE_DIR))
292
+ dry_run: dry_run, shipped_dir: File.dirname(GEM_BUNDLE_DIR))
282
293
  end
283
294
  private_class_method :with_lock, :note_newer_installed, :install_fresh, :keep_manifest, :upgrade_existing, :verify_files_present, :restore_file, :remerge,
284
295
  :upgrade_installer, :result, :planned_update
@@ -99,24 +99,19 @@ module Samagotchi
99
99
  dest
100
100
  end
101
101
 
102
- # Copies the file at +path+ as +as+ before it is replaced or removed, when
103
- # that would lose something: its bytes are neither what the record says
104
- # was installed (no sha recorded: unknown, so it counts as edited) nor
105
- # +incoming+'s. A link (a dev install's) points at its source, which
106
- # nothing here touches.
107
- def copy_edit(path, as:, recorded: nil, incoming: nil)
108
- copy(path, as: as) if self.class.edit?(path, recorded: recorded, incoming: incoming)
109
- end
110
-
111
- # copy_edit for every file under +dir+, which is about to go, at any depth
112
- # (kept as <label>/<its path in dir>). A linked dir isn't followed.
102
+ # Copies every file under +dir+, which is about to be replaced or
103
+ # removed, when that would lose something: its bytes are neither what
104
+ # the record says was installed (no sha recorded: unknown, so it counts
105
+ # as edited) nor the incoming source's, at any depth (kept as
106
+ # <label>/<its path in dir>). A link (a dev install's) points at its
107
+ # source, which nothing here touches; a linked dir isn't followed.
113
108
  # @param recorded [Hash, nil] path in dir => its recorded sha256
114
109
  # @param incoming [Hash] path in dir => the source replacing it
115
110
  def copy_edits(dir, label, recorded = nil, incoming = {})
116
111
  self.class.edited_in(dir, recorded, incoming).each { |file| copy(File.join(dir, file), as: "#{label}/#{file}") }
117
112
  end
118
113
 
119
- # Whether #copy_edit would copy the file at +path+ (read-only): the rule
114
+ # Whether #copy_edits would copy the file at +path+ (read-only): the rule
120
115
  # ShippedUpdate's plan names its replaced files by.
121
116
  def self.edit?(path, recorded: nil, incoming: nil)
122
117
  return false if File.symlink?(path) || !File.file?(path)
@@ -19,14 +19,14 @@ module Samagotchi
19
19
 
20
20
  module_function
21
21
 
22
- # The orphan candidates for +cwd+'s project, or nil: only inside a git
23
- # repository (outside one the root is cwd, and every src/ or tmp/ would
24
- # match), and only while the computed folder holds no *.md (a
22
+ # The orphan candidates for +cwd+'s project, or nil: only inside a
23
+ # project, a git repository or a marked folder (outside both the root is
24
+ # cwd, and every src/ or tmp/ would match), and only while the computed folder holds no *.md (a
25
25
  # memory_write's mkdir_p or a project-scope bundle install alone must
26
26
  # not silence it).
27
27
  # @return [Found, nil]
28
28
  def find(env: ENV, cwd: Dir.pwd)
29
- return nil unless MemoryPaths.in_repo?(cwd)
29
+ return nil unless MemoryPaths.in_project?(cwd)
30
30
 
31
31
  projects = MemoryPaths.projects_dir(env: env)
32
32
  computed = MemoryPaths.project_dir(env: env, cwd: cwd)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "digest"
4
4
  require_relative "config"
5
+ require_relative "guardrails/path_resolve"
5
6
 
6
7
  module Samagotchi
7
8
  # Where memories live: <config dir>/memories, i.e. $XDG_CONFIG_HOME/samagotchi/memories
@@ -9,11 +10,61 @@ module Samagotchi
9
10
  # ConfigFile.global_path, so an XDG_CONFIG_HOME set after load (specs, smoke runs,
10
11
  # a fixture chi) moves memories together with config.yml.
11
12
  #
12
- # Project memories are keyed by the project root (see project_root), so every
13
- # worktree and subdirectory of one git repository shares one folder.
13
+ # Project memories are keyed by the project root (see #lookup): one folder
14
+ # per project root, a git repository (every worktree and subdirectory of it
15
+ # shares one folder) or a folder marked by `chi project init` (and its
16
+ # subfolders).
14
17
  module MemoryPaths
18
+ # The file that makes a folder in no git repository a chi project
19
+ # (`chi project init`). The file, not the bare .chi/ dir, is the marker.
20
+ MARKER = File.join(".chi", "project.yml").freeze
21
+
22
+ # How a folder's project was found (#lookup): +kind+ is :git, :marker or
23
+ # :none; +root+ is what memories are keyed on (#project_root: git's
24
+ # common-dir checkout, the marked folder's realpath, or the folder
25
+ # itself); +top+ is the folder a session reads its AGENTS.md chain from
26
+ # (the work tree's top, the marked root; nil for :none).
27
+ ProjectLookup = Data.define(:kind, :root, :top) do
28
+ def project? = kind != :none
29
+ def git? = kind == :git
30
+ def marker? = kind == :marker
31
+ end
32
+
15
33
  module_function
16
34
 
35
+ # The project +cwd+ belongs to, looked up once: a .git above it wins
36
+ # (git is the identity wherever it exists); only when there is none, the
37
+ # nearest MARKER above it, ignoring one in $HOME or any folder above it
38
+ # (and /), so a marker in ~ never makes every folder under home one
39
+ # project. The marker walk starts from +cwd+'s real path and the marked
40
+ # root is a realpath, as git's root is, so every spelling of a folder
41
+ # (/tmp, /var on macOS, a symlink into or out of a marked tree) gets the
42
+ # answer the worker's physical Dir.pwd gets.
43
+ # @param home [String, nil] $HOME by default; nil or blank ignores only /
44
+ # @return [ProjectLookup]
45
+ def lookup(cwd = Dir.pwd, home: default_home)
46
+ dot_git = find_dot_git(cwd)
47
+ return ProjectLookup.new(kind: :git, root: git_root(dot_git), top: File.dirname(dot_git)) if dot_git
48
+
49
+ marked = nearest_marker(cwd, home: home)
50
+ return ProjectLookup.new(kind: :marker, root: marked, top: marked) if marked
51
+
52
+ ProjectLookup.new(kind: :none, root: cwd, top: nil)
53
+ end
54
+
55
+ # Whether a marker at +dir+ is ignored: +dir+ is / or holds $HOME (both
56
+ # resolved; Guardrails::PathResolve.home_root?).
57
+ def above_home?(dir, home: default_home)
58
+ Guardrails::PathResolve.real(dir) == "/" || Guardrails::PathResolve.home_root?(dir, home)
59
+ end
60
+
61
+ # $HOME, or nil when it can't be found (no HOME, no passwd entry).
62
+ def default_home
63
+ Dir.home
64
+ rescue ArgumentError
65
+ nil
66
+ end
67
+
17
68
  def system_dir(env: ENV)
18
69
  File.join(ConfigFile.config_dir(env: env), "memories")
19
70
  end
@@ -28,16 +79,31 @@ module Samagotchi
28
79
  # #project_key for a root already resolved (a session's project_root).
29
80
  def key_for_root(root) = "#{File.basename(root)}_#{Digest::MD5.hexdigest(root)[0..7]}"
30
81
 
31
- # The repository's common git directory identifies the project; every linked
32
- # worktree shares it. Returns the checkout holding it when it is named .git,
33
- # otherwise the git dir itself (a bare repo, a proj/.bare layout,
34
- # --separate-git-dir, a submodule's .git/modules/<name>), so for those layouts
35
- # the "project root" is a git dir, not a checkout. Outside any repository the
36
- # root is cwd. No git subprocess: at most two small files are read.
37
- def project_root(cwd = Dir.pwd)
38
- dot_git = find_dot_git(cwd)
39
- return cwd unless dot_git
82
+ # The project root (#lookup): in a repository, its common git directory
83
+ # identifies the project; every linked worktree shares it. Returns the
84
+ # checkout holding it when it is named .git, otherwise the git dir itself
85
+ # (a bare repo, a proj/.bare layout, --separate-git-dir, a submodule's
86
+ # .git/modules/<name>), so for those layouts the "project root" is a git
87
+ # dir, not a checkout. Outside any repository, the marked folder holding
88
+ # +cwd+, else cwd. No git subprocess: at most two small files are read.
89
+ def project_root(cwd = Dir.pwd) = lookup(cwd).root
90
+
91
+ # Is +cwd+ in a project: a git repository or a marked folder?
92
+ def in_project?(cwd = Dir.pwd) = lookup(cwd).project?
93
+
94
+ # The marked folder (realpath) +cwd+'s project is, or nil (in a git
95
+ # repository, or in no project).
96
+ def marked_root(cwd = Dir.pwd)
97
+ found = lookup(cwd)
98
+ found.marker? ? found.root : nil
99
+ end
100
+
101
+ # The top of +cwd+'s project: the git work tree's top (#work_tree_root) or
102
+ # the marked root; nil in no project.
103
+ def project_top(cwd = Dir.pwd) = lookup(cwd).top
40
104
 
105
+ # The project root of the checkout whose .git is +dot_git+ (#project_root).
106
+ def git_root(dot_git)
41
107
  holder = File.dirname(dot_git)
42
108
  gitdir = File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
43
109
  return holder unless gitdir
@@ -78,7 +144,8 @@ module Samagotchi
78
144
  File.directory?(dot_git) ? dot_git : gitdir_from_file(dot_git)
79
145
  end
80
146
 
81
- # Is +cwd+ inside a git repository (a .git somewhere above it)?
147
+ # Is +cwd+ inside a git repository (a .git somewhere above it)? Git only:
148
+ # a marked folder is not a repository (#in_project?).
82
149
  def in_repo?(cwd = Dir.pwd)
83
150
  !find_dot_git(cwd).nil?
84
151
  end
@@ -96,6 +163,28 @@ module Samagotchi
96
163
  end
97
164
  end
98
165
 
166
+ # The realpath of the nearest folder at or above +start+ holding MARKER,
167
+ # git or not (#lookup asks only outside a repository; `chi project` tells
168
+ # of one a repository makes ignored), or nil. A marker at or above +home+
169
+ # (#above_home?) ends the walk with nil: every folder above it is above
170
+ # home too.
171
+ def nearest_marker(start, home: default_home)
172
+ dir = Guardrails::PathResolve.real(start)
173
+ loop do
174
+ if File.file?(File.join(dir, MARKER))
175
+ return nil if above_home?(dir, home: home)
176
+
177
+ return File.realpath(dir)
178
+ end
179
+ parent = File.dirname(dir)
180
+ return nil if parent == dir
181
+
182
+ dir = parent
183
+ end
184
+ rescue SystemCallError
185
+ nil
186
+ end
187
+
99
188
  # A ".git" file holds "gitdir: <path>", relative to the file's directory.
100
189
  def gitdir_from_file(path)
101
190
  line = File.read(path, 4096).to_s[/\Agitdir:\s*(.+)$/, 1]&.strip
@@ -115,7 +204,7 @@ module Samagotchi
115
204
  rescue SystemCallError
116
205
  gitdir
117
206
  end
118
- private_class_method :find_dot_git, :gitdir_from_file, :common_dir
207
+ private_class_method :find_dot_git, :git_root, :gitdir_from_file, :common_dir
119
208
 
120
209
  def project_dir(env: ENV, cwd: Dir.pwd)
121
210
  File.join(projects_dir(env: env), project_key(cwd))
@@ -32,10 +32,7 @@ module Samagotchi
32
32
  # plugin writes it (ApiCalls#keywords: `ctx.sessions.send(…,
33
33
  # delivery:)` is seen, a `sessions = ctx.sessions` alias isn't).
34
34
  # Events aren't listed: chi.on takes any symbol and an older chi never
35
- # fires one it doesn't know (harmless; chi bundle check warns on it),
36
- # and a manifest hook's events: list (BundleHook) needs the bundle's
37
- # requires_chi set by hand (docs/hooks.md, Bundle Hooks): no source
38
- # scan covers manifest.yml.
35
+ # fires one it doesn't know (harmless; chi bundle check warns on it).
39
36
  SINCE = {
40
37
  "ctx.steer" => "0.3.0", "ctx.stop_turn" => "0.3.0", "ctx.stop_generation" => "0.6.0",
41
38
  "Samagotchi::AtomicFile" => "0.8.0", "ctx.model" => "0.17.0", "ctx.model_key" => "0.17.0",
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require_relative "api"
4
5
  require_relative "context"
5
6
  require_relative "../hooks"
@@ -10,6 +11,11 @@ require_relative "../memory_bundle/manifest"
10
11
  require_relative "../memory_bundle/provenance"
11
12
 
12
13
  module Samagotchi
14
+ # Where installed bundles' plugin code is evaluated: one module per
15
+ # bundle (Plugin::Loader.namespace_for), so two bundles defining the same
16
+ # class name can't collide on a top-level constant.
17
+ module Bundles; end
18
+
13
19
  module Plugin
14
20
  # What a plugin registers into: an Engine's own registries.
15
21
  # +context_for+ is (bundle_name, settings, label) → the Plugin::Context
@@ -36,7 +42,7 @@ module Samagotchi
36
42
  #
37
43
  # For each bundle, by name: the file must match the sha256 recorded at
38
44
  # install and chi must meet the bundle's requires_chi. The file is
39
- # module_eval'd into the bundle's namespace (as its hooks are), the
45
+ # module_eval'd into the bundle's namespace (#namespace_for), the
40
46
  # class named like the file (plugin.rb → Plugin) is built with the
41
47
  # bundle's settings, and its #register gets a Plugin::Api. What it
42
48
  # registered takes effect only when #register returns: a plugin that
@@ -140,14 +146,45 @@ module Samagotchi
140
146
  return failure if failure
141
147
  return "the file is missing" unless file && File.file?(file)
142
148
 
143
- Hooks::BundleLoader.sha_mismatch(file, bundle.plugin.sha256, required: true)
149
+ sha_mismatch(file, bundle.plugin.sha256)
150
+ end
151
+
152
+ # Why +file+ doesn't match the sha256 recorded at install (+recorded+,
153
+ # "sha256:<hex>" or bare hex), or nil. None recorded is a mismatch;
154
+ # Provenance.unchecked? (a dev install) is none.
155
+ def sha_mismatch(file, recorded)
156
+ return nil if MemoryBundle::Provenance.unchecked?(recorded)
157
+
158
+ expected = MemoryBundle::Provenance.recorded_sha(recorded)
159
+ return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
160
+
161
+ actual = MemoryBundle::Provenance.file_sha(file)
162
+ return nil if actual == expected
163
+
164
+ "its sha256 #{actual[0, 12]}… differs from the installed #{expected[0, 12]}… (edited after install? reinstall the bundle)"
165
+ end
166
+
167
+ # The (lazily created) namespace module for a bundle, under
168
+ # Samagotchi::Bundles. The name is sanitized and made injective by a
169
+ # short digest, so `my-bundle` and `my_bundle` don't collide.
170
+ def namespace_for(bundle_name)
171
+ raw = bundle_name.to_s
172
+ base = raw.gsub(/[^A-Za-z0-9_]/, "_")
173
+ base = "B_#{base}" unless base.match?(/\A[A-Z]/)
174
+ ns_name = :"#{base}_#{Digest::MD5.hexdigest(raw)[0, 4]}"
175
+ if Samagotchi::Bundles.const_defined?(ns_name, false)
176
+ Samagotchi::Bundles.const_get(ns_name)
177
+ else
178
+ Samagotchi::Bundles.const_set(ns_name, Module.new)
179
+ end
144
180
  end
145
181
 
146
182
  # module_eval the file into a fresh module in the bundle's namespace
147
183
  # (Samagotchi::Bundles::<bundle>::PluginLoad<n>: each load gets new
148
184
  # classes, not the last load's reopened) and build its class
149
- # (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as hooks are built:
150
- # an initialize that takes an argument gets the settings.
185
+ # (plugin.rb → Plugin; my_plugin.rb → MyPlugin) as config hooks are
186
+ # built (Hooks.build_plugin): an initialize that takes an argument
187
+ # gets the settings.
151
188
  def instantiate(bundle_name, file, settings, namespace: nil)
152
189
  namespace ||= fresh_namespace(bundle_name)
153
190
  namespace.module_eval(File.read(file), file, 1)
@@ -163,7 +200,7 @@ module Samagotchi
163
200
  def fresh_namespace(bundle_name)
164
201
  LOADS.synchronize do
165
202
  @loads = (@loads || 0) + 1
166
- Hooks::BundleLoader.namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
203
+ namespace_for(bundle_name).const_set(:"PluginLoad#{@loads}", Module.new)
167
204
  end
168
205
  end
169
206