textus 0.40.0 → 0.42.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.
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # ADR 0047 Decision 4. A publish_tree entry prunes its WHOLE target dir on
5
+ # every build. If a derived entry's publish_to writes a file into that same
6
+ # dir, the tree's prune will delete it unless the tree `ignore`s that
7
+ # filename. Warn so the author adds the ignore before prune eats the index.
8
+ class PublishTreeIndexOverlap < Check
9
+ def call
10
+ entries = manifest.data.entries
11
+ trees = entries.select { |e| e.nested? && e.publish_tree }
12
+ return [] if trees.empty?
13
+
14
+ derived_targets = entries.flat_map do |e|
15
+ Array(e.publish_to).map { |rel| [e, rel] }
16
+ end
17
+
18
+ trees.flat_map do |tree|
19
+ target_prefix = "#{tree.publish_tree.chomp("/")}/"
20
+ derived_targets.filter_map do |(derived, rel)|
21
+ next nil unless rel.start_with?(target_prefix)
22
+
23
+ rel_to_target = rel.delete_prefix(target_prefix)
24
+ next nil if tree.ignored?(rel_to_target)
25
+
26
+ issue(tree, derived, rel, rel_to_target)
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def issue(tree, derived, rel, rel_to_target)
34
+ basename = File.basename(rel_to_target)
35
+ {
36
+ "code" => "publish.tree_index_overlap",
37
+ "level" => "warning",
38
+ "subject" => tree.key,
39
+ "message" => "publish_tree '#{tree.publish_tree}' overlaps derived entry " \
40
+ "'#{derived.key}' publish_to '#{rel}'; the tree's prune will delete it on rebuild",
41
+ "fix" => "add a glob covering '#{rel_to_target}' to entry '#{tree.key}' ignore " \
42
+ "(e.g. ignore: [\"**/#{basename}\"])",
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
data/lib/textus/doctor.rb CHANGED
@@ -25,6 +25,7 @@ module Textus
25
25
  Check::HandlerAllowlist,
26
26
  Check::FetchLocks,
27
27
  Check::OrphanedPublishTargets,
28
+ Check::PublishTreeIndexOverlap,
28
29
  Check::ProposalTargets,
29
30
  ].freeze
30
31
 
@@ -43,7 +43,7 @@ module Textus
43
43
  def template = nil
44
44
  def inject_boot = false # rubocop:disable Naming/PredicateMethod
45
45
  def events = {}
46
- def publish_each = nil
46
+ def publish_tree = nil
47
47
  def index_filename = nil
48
48
  def ignore = []
49
49
 
@@ -78,27 +78,18 @@ module Textus
78
78
  end
79
79
  end
80
80
 
81
- # Subclasses override to customize publish behavior.
82
- # Default: copy the stored file to each publish_to target.
83
- # Returns: { kind: :built|:leaves, value: ... } to be accumulated by
84
- # Publish#call, or nil to skip.
85
- def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
86
- return nil if Array(publish_to).empty?
87
-
88
- source_path = pctx.manifest.resolver.resolve(@key).path
89
- envelope = pctx.reader.call(@key)
90
-
91
- publish_to.each do |rel|
92
- target_abs = File.join(pctx.repo_root, rel)
93
- Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
94
- pctx.emit(:file_published,
95
- key: @key,
96
- envelope: envelope,
97
- source: source_path,
98
- target: target_abs)
99
- end
81
+ # ADR 0049: an entry resolves, once, to one Publish::* mode that owns its
82
+ # publish algorithm. A plain entry publishes via ToPaths (publish_to) or
83
+ # None; Nested resolves among the key/path-driven modes. Derived
84
+ # overrides publish_via to materialize first.
85
+ def publish_mode
86
+ @publish_mode ||= Publish.resolve(self)
87
+ end
100
88
 
101
- { kind: :built, value: { "key" => @key, "path" => source_path, "published_to" => publish_to } }
89
+ # Returns: { kind: :built|:leaves, value: ... } to be accumulated by
90
+ # Write::Publish, or nil to skip.
91
+ def publish_via(pctx, prefix: nil)
92
+ publish_mode.publish(pctx, prefix: prefix)
102
93
  end
103
94
  end
104
95
  end
@@ -1,16 +1,17 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Entry
4
+ # A directory entry: enumerates a tree of leaves and resolves to one
5
+ # publish mode (ADR 0049). The publish algorithms themselves live in
6
+ # Entry::Publish::* — Nested is just the value (attributes + ignore
7
+ # predicate) those modes read.
4
8
  class Nested < Base
5
- PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
6
- PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
9
+ attr_reader :index_filename, :publish_tree, :ignore
7
10
 
8
- attr_reader :index_filename, :publish_each, :ignore
9
-
10
- def initialize(index_filename: nil, publish_each: nil, ignore: nil, **rest)
11
+ def initialize(index_filename: nil, publish_tree: nil, ignore: nil, **rest)
11
12
  super(**rest)
12
13
  @index_filename = index_filename
13
- @publish_each = publish_each
14
+ @publish_tree = publish_tree
14
15
  @ignore = Array(ignore)
15
16
  end
16
17
 
@@ -21,96 +22,12 @@ module Textus
21
22
  # an ignored path is excluded, never judged.
22
23
  def ignored?(rel_path) = IgnoreMatcher.match?(@ignore, rel_path)
23
24
 
24
- def publish_target_for(full_key)
25
- return nil if @publish_each.nil?
26
-
27
- entry_segs = @key.split(".")
28
- key_segs = full_key.split(".")
29
- raise UsageError.new("key '#{full_key}' is not under entry '#{@key}'") unless key_segs[0, entry_segs.length] == entry_segs
30
-
31
- remaining = key_segs[entry_segs.length..] || []
32
- leaf = remaining.join("/")
33
- basename = remaining.last || ""
34
- ext = Textus::Entry.for_format(@format).extensions.first.to_s.sub(/^\./, "")
35
-
36
- vars = { "leaf" => leaf, "basename" => basename, "key" => full_key, "ext" => ext }
37
- @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
38
- end
39
-
40
- def publish_via(pctx, prefix: nil)
41
- return nil if @publish_each.nil?
42
-
43
- leaves = []
44
- pruned = [] # accumulates orphans removed by prune_orphans below
45
- pctx.manifest.resolver.enumerate(prefix: @key).each do |row|
46
- next unless row[:manifest_entry].equal?(self)
47
- next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
48
-
49
- target_rel = publish_target_for(row[:key])
50
- target_abs = File.expand_path(File.join(pctx.repo_root, target_rel))
51
- unless target_abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
52
- raise Textus::PublishError.new(
53
- "entry '#{@key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
54
- )
55
- end
56
-
57
- written = @index_filename ? publish_subtree(row, target_abs, pctx) : [publish_one(row, target_abs, pctx)]
58
- pruned.concat(prune_orphans(target_abs, written, pctx)) if @index_filename
59
- written.each { |w| leaves << { "key" => row[:key], "source" => w["source"], "target" => w["target"] } }
60
- end
61
-
62
- { kind: :leaves, value: leaves, pruned: pruned }
63
- end
64
-
65
- def publish_one(row, target_abs, pctx)
66
- Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
67
- pctx.emit(:file_published, key: row[:key], envelope: pctx.reader.call(row[:key]),
68
- source: row[:path], target: target_abs)
69
- { "source" => row[:path], "target" => target_abs }
70
- end
71
-
72
- def publish_subtree(row, target_dir, pctx)
73
- base = File.join(pctx.root, "zones", path)
74
- leaf_dir = File.dirname(row[:path])
75
- # FNM_DOTMATCH includes dotfiles; File.file? below skips dirs (and symlinks-to-dirs). Leaf trees are authored content, not arbitrary symlink graphs.
76
- Dir.glob(File.join(leaf_dir, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |src|
77
- next nil unless File.file?(src)
78
-
79
- rel_to_base = src.sub(%r{\A#{Regexp.escape(base)}/}, "")
80
- next nil if ignored?(rel_to_base)
81
-
82
- rel_to_leaf = src.sub(%r{\A#{Regexp.escape(leaf_dir)}/}, "")
83
- dst = File.join(target_dir, rel_to_leaf)
84
- Textus::Ports::Publisher.publish(source: src, target: dst, store_root: pctx.root)
85
- pctx.emit(:file_published, key: row[:key], envelope: pctx.reader.call(row[:key]),
86
- source: src, target: dst)
87
- { "source" => src, "target" => dst }
88
- end
89
- end
90
-
91
- # Scoped to this leaf's target_dir only. Safe across leaves because ADR 0046
92
- # Decision 5 (shallowest-index-wins) keeps leaf dirs non-nesting, so {leaf}-derived
93
- # target dirs never nest and targets_under can't reach another leaf's sentinels.
94
- def prune_orphans(target_dir, written, pctx)
95
- kept = written.map { |w| File.expand_path(w["target"]) }
96
- store = Textus::Ports::SentinelStore.new
97
- store.targets_under(target_dir, pctx.root).filter_map do |managed|
98
- next nil if kept.include?(File.expand_path(managed))
99
-
100
- Textus::Ports::Publisher.unpublish(target: managed, store_root: pctx.root)
101
- managed
102
- end
103
- end
104
-
105
- # Helpers are private; KIND / self.from_raw / REGISTRY below are intentionally public.
106
- private :publish_one, :publish_subtree, :prune_orphans
107
-
108
25
  KIND = :nested
109
26
 
110
27
  def self.from_raw(common, raw)
111
28
  new(
112
29
  index_filename: raw["index_filename"],
113
- publish_each: raw["publish_each"],
30
+ publish_tree: raw["publish_tree"],
114
31
  ignore: raw["ignore"],
115
32
  **common,
116
33
  )
@@ -0,0 +1,39 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # Base for every publish mode: wraps the resolved entry and owns the one
6
+ # repo-root escape guard the writing modes share (ADR 0049). Subclasses
7
+ # implement `#publish(pctx, prefix:)` returning the existing
8
+ # `{ kind:, value:, pruned: }` shape (or nil), and `#validate!` for the
9
+ # per-mode shape rules reached *because* this mode resolved.
10
+ class Mode
11
+ def initialize(entry)
12
+ @entry = entry
13
+ end
14
+
15
+ attr_reader :entry
16
+
17
+ # No shape rules by default — ToPaths/None publish without templating.
18
+ def validate!; end
19
+
20
+ private
21
+
22
+ # Expand `rel` under repo_root and confirm it stays inside it.
23
+ def repo_abs(pctx, rel)
24
+ File.expand_path(File.join(pctx.repo_root, rel))
25
+ end
26
+
27
+ def inside_repo?(pctx, abs)
28
+ abs.start_with?(File.expand_path(pctx.repo_root) + File::SEPARATOR)
29
+ end
30
+
31
+ # Store-side directory this entry's tree lives under.
32
+ def store_base(pctx)
33
+ File.join(pctx.root, "zones", entry.path)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,14 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # An entry with no publish_* key — nothing to publish.
6
+ class None < Mode
7
+ def publish(_pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
8
+ nil
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,72 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # ADR 0049: the one walk->publish->prune pipeline behind Tree (whole-entry
6
+ # mirror, ADR 0047). It was once shared with the per-leaf publish_each
7
+ # mode too; ADR 0051 removed publish_each, leaving Tree the only caller.
8
+ # The `walk_root`/`prune_honors_ignore:` parameters survive from that
9
+ # shared shape — Tree always walks at `base` and honors `ignore` in the
10
+ # prune (ADR 0047 D4, so a derived index in the mirrored dir survives).
11
+ class SubtreeMirror
12
+ def initialize(entry, pctx)
13
+ @entry = entry
14
+ @pctx = pctx
15
+ end
16
+
17
+ # base: store dir the entry owns — the root `ignored?` globs are
18
+ # relative to (ADR 0042).
19
+ # walk_root: dir the glob is rooted at (== base for Tree). dst paths
20
+ # mirror rel-to-walk_root.
21
+ # target_dir: repo-side destination root.
22
+ # key/envelope: emitted per file; envelope is nil for the keyless Tree.
23
+ # prune_honors_ignore: when true a managed file the entry `ignore`s
24
+ # survives the prune (ADR 0047 D4 — lets a derived index live in the
25
+ # mirrored dir); when false every unwritten managed file is pruned.
26
+ def mirror(base:, walk_root:, target_dir:, key:, envelope:, prune_honors_ignore:)
27
+ return { written: [], pruned: [] } unless File.directory?(walk_root)
28
+
29
+ written = publish_files(base: base, walk_root: walk_root, target_dir: target_dir, key: key, envelope: envelope)
30
+ { written: written, pruned: prune(target_dir, written, prune_honors_ignore) }
31
+ end
32
+
33
+ private
34
+
35
+ def publish_files(base:, walk_root:, target_dir:, key:, envelope:)
36
+ # FNM_DOTMATCH includes dotfiles; File.file? below skips dirs (and
37
+ # symlinks-to-dirs). Trees are authored content, not symlink graphs.
38
+ Dir.glob(File.join(walk_root, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |src|
39
+ next nil unless File.file?(src)
40
+ next nil if @entry.ignored?(relative(src, base))
41
+
42
+ dst = File.join(target_dir, relative(src, walk_root))
43
+ Textus::Ports::Publisher.publish(source: src, target: dst, store_root: @pctx.root)
44
+ @pctx.emit(:file_published, key: key, envelope: envelope, source: src, target: dst)
45
+ { "key" => key, "source" => src, "target" => dst }
46
+ end
47
+ end
48
+
49
+ # Scoped to target_dir. Safe across leaves because ADR 0046 D5
50
+ # (shallowest-index-wins) keeps leaf target dirs non-nesting, so
51
+ # targets_under can't reach another leaf's sentinels.
52
+ def prune(target_dir, written, honor_ignore)
53
+ kept = written.map { |w| File.expand_path(w["target"]) }
54
+ store = Textus::Ports::SentinelStore.new
55
+ store.targets_under(target_dir, @pctx.root).filter_map do |managed|
56
+ abs = File.expand_path(managed)
57
+ next nil if kept.include?(abs)
58
+ next nil if honor_ignore && @entry.ignored?(relative(abs, target_dir))
59
+
60
+ Textus::Ports::Publisher.unpublish(target: managed, store_root: @pctx.root)
61
+ managed
62
+ end
63
+ end
64
+
65
+ def relative(path, root)
66
+ path.sub(%r{\A#{Regexp.escape(root)}/}, "")
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # Template-variable detection for publish targets. The only surviving
6
+ # use after ADR 0051 (which removed publish_each and its `{leaf}`/
7
+ # `{basename}`/`{key}`/`{ext}` vocabulary) is Tree.validate!, which uses
8
+ # VAR_RE to reject any `{var}` in a publish_tree value — that key names a
9
+ # single directory by plain path and interprets no variables.
10
+ module Template
11
+ VAR_RE = /\{([a-z]+)\}/
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # publish_to: copy the entry's one stored file to each fixed repo path.
6
+ # The default behaviour of any entry that declares `publish_to:`.
7
+ class ToPaths < Mode
8
+ def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
9
+ targets = Array(entry.publish_to)
10
+ return nil if targets.empty?
11
+
12
+ source_path = pctx.manifest.resolver.resolve(entry.key).path
13
+ envelope = pctx.reader.call(entry.key)
14
+
15
+ targets.each do |rel|
16
+ target_abs = File.join(pctx.repo_root, rel)
17
+ Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
18
+ pctx.emit(:file_published, key: entry.key, envelope: envelope, source: source_path, target: target_abs)
19
+ end
20
+
21
+ { kind: :built, value: { "key" => entry.key, "path" => source_path, "published_to" => targets } }
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Publish
5
+ # publish_tree (ADR 0047): mirror this entry's whole subtree to one
6
+ # target dir by real path. No resolver, no keys — files are opaque
7
+ # payload (envelope nil). The prune honors `ignore` so a derived index
8
+ # (e.g. a SKILL.md written by a separate entry into the same dir)
9
+ # survives the whole-target prune (ADR 0047 D4).
10
+ class Tree < Mode
11
+ def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
12
+ target_rel = entry.publish_tree
13
+ target_dir = repo_abs(pctx, target_rel)
14
+ unless inside_repo?(pctx, target_dir)
15
+ raise Textus::PublishError.new(
16
+ "entry '#{entry.key}': publish_tree target '#{target_rel}' escapes repo root",
17
+ )
18
+ end
19
+
20
+ result = SubtreeMirror.new(entry, pctx).mirror(
21
+ base: store_base(pctx),
22
+ walk_root: store_base(pctx),
23
+ target_dir: target_dir,
24
+ key: entry.key,
25
+ envelope: nil,
26
+ prune_honors_ignore: true,
27
+ )
28
+ { kind: :leaves, value: result[:written], pruned: result[:pruned] }
29
+ end
30
+
31
+ def validate!
32
+ publish_tree = entry.publish_tree
33
+ raise UsageError.new("entry '#{entry.key}': publish_tree must be a string") unless publish_tree.is_a?(String)
34
+
35
+ unless entry.index_filename.nil?
36
+ raise UsageError.new(
37
+ "entry '#{entry.key}': index_filename and publish_tree are mutually exclusive — " \
38
+ "publish_tree mirrors a whole subtree by path and never enumerates an index.",
39
+ )
40
+ end
41
+
42
+ used_vars = publish_tree.scan(Template::VAR_RE).flatten
43
+ return if used_vars.empty?
44
+
45
+ raise UsageError.new(
46
+ "entry '#{entry.key}': publish_tree names a single directory and takes no template variable(s) " \
47
+ "#{used_vars.map { |v| "{#{v}}" }.join(", ")} — it mirrors the whole subtree to one target dir.",
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,55 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ # ADR 0049: the publish design is a key-split concept (ADR 0047 table)
5
+ # realized as one resolved sum type. Each directory entry resolves, once,
6
+ # to one Publish::* mode that owns its publish algorithm — no nil-cascade,
7
+ # no pairwise exclusivity guards, one shared subtree mirror. ADR 0051
8
+ # removed `publish_each` (both leaf modes); the surface is now two modes:
9
+ #
10
+ # None — nothing to publish
11
+ # ToPaths — publish_to: 1 stored file -> N fixed repo paths
12
+ # Tree — publish_tree: whole entry subtree -> 1 dir, no keys
13
+ module Publish
14
+ # Resolve an entry to its single publish mode. Raises one UsageError if
15
+ # both publish_to and publish_tree are set — exclusivity is structural
16
+ # here, not scattered pairwise guards. A removed `publish_each:` key is
17
+ # rejected loudly with its replacement (ADR 0051).
18
+ def self.resolve(entry)
19
+ reject_removed_publish_each(entry)
20
+
21
+ set = []
22
+ set << "publish_to" unless Array(entry.publish_to).empty?
23
+ set << "publish_tree" unless entry.publish_tree.nil?
24
+
25
+ if set.length > 1
26
+ raise Textus::UsageError.new(
27
+ "entry '#{entry.key}': #{set.join(", ")} are mutually exclusive — an entry publishes exactly one way",
28
+ )
29
+ end
30
+
31
+ mode_for(entry, set.first)
32
+ end
33
+
34
+ def self.reject_removed_publish_each(entry)
35
+ return unless entry.raw["publish_each"]
36
+
37
+ raise Textus::UsageError.new(
38
+ "entry '#{entry.key}': publish_each was removed in 0.42.0 (ADR 0051) — " \
39
+ "mirror the subtree with publish_tree (and index_filename to keep the index addressable).",
40
+ )
41
+ end
42
+ private_class_method :reject_removed_publish_each
43
+
44
+ def self.mode_for(entry, key)
45
+ case key
46
+ when "publish_to" then ToPaths.new(entry)
47
+ when "publish_tree" then Tree.new(entry)
48
+ else None.new(entry)
49
+ end
50
+ end
51
+ private_class_method :mode_for
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ class Manifest
3
+ class Entry
4
+ module Validators
5
+ # ADR 0049: one publish validator. Exclusivity among the publish keys is
6
+ # enforced structurally by Publish.resolve (reached via #publish_mode),
7
+ # and each mode's shape rules run *because that mode resolved* — replacing
8
+ # the scattered pairwise "not-both" guards of the old PublishEach +
9
+ # PublishTree validators. Misuse on a non-nested entry is still caught
10
+ # here from raw, since the typed attrs stub nil on Base. (publish_each was
11
+ # removed in 0.42.0 — ADR 0051; Schema rejects it at load.)
12
+ module Publish
13
+ def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
14
+ unless entry.nested?
15
+ raise UsageError.new("entry '#{entry.key}': publish_tree requires nested: true") if entry.raw["publish_tree"]
16
+
17
+ return
18
+ end
19
+
20
+ entry.publish_mode.validate!
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -4,7 +4,7 @@ module Textus
4
4
  module Validators
5
5
  REGISTERED = [
6
6
  Events,
7
- PublishEach,
7
+ Publish,
8
8
  InjectBoot,
9
9
  IndexFilename,
10
10
  Ignore,
@@ -24,7 +24,7 @@ module Textus
24
24
  KIND_REQUIRES_VERB = LANES
25
25
  ENTRY_KEYS = %w[
26
26
  key path zone kind schema owner nested format
27
- compute template publish_to publish_each
27
+ compute template publish_to publish_tree
28
28
  intake events inject_boot index_filename ignore tracked
29
29
  ].freeze
30
30
  COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
@@ -73,12 +73,25 @@ module Textus
73
73
  def self.validate_entries!(entries)
74
74
  Array(entries).each_with_index do |e, i|
75
75
  path = "$.entries[#{i}]"
76
+ reject_removed_publish_each!(e, path)
76
77
  walk(e, ENTRY_KEYS, path)
77
78
  walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
78
79
  walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
79
80
  end
80
81
  end
81
82
 
83
+ # publish_each was removed in 0.42.0 (ADR 0051). It is no longer an allowed
84
+ # entry key, so `walk` would reject it as merely "unknown"; intercept it
85
+ # first with the migration path so a pre-0.42 manifest gets a useful error.
86
+ def self.reject_removed_publish_each!(entry, path)
87
+ return unless entry.is_a?(Hash) && entry.key?("publish_each")
88
+
89
+ raise BadManifest.new(
90
+ "publish_each was removed in 0.42.0 (ADR 0051) at '#{path}' — " \
91
+ "mirror the subtree with publish_tree (and index_filename to keep the index addressable).",
92
+ )
93
+ end
94
+
82
95
  def self.validate_rules!(rules)
83
96
  Array(rules).each_with_index do |r, i|
84
97
  path = "$.rules[#{i}]"
@@ -12,7 +12,7 @@ module Textus
12
12
  module Publisher
13
13
  def self.publish(source:, target:, store_root:)
14
14
  FileUtils.mkdir_p(File.dirname(target))
15
- refuse_if_unmanaged(target, store_root)
15
+ guard_clobber(source, target, store_root)
16
16
  File.delete(target) if File.symlink?(target)
17
17
  FileUtils.cp(source, target)
18
18
  Textus::Ports::SentinelStore.new.write!(target: target, source: source, store_root: store_root)
@@ -28,13 +28,25 @@ module Textus
28
28
  FileUtils.rm_f(sentinel)
29
29
  end
30
30
 
31
- def self.refuse_if_unmanaged(target, store_root)
31
+ # Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
32
+ # already equal the source (ADR 0050: a migration copies files into the
33
+ # store and publishes them back to where they already live, so the target
34
+ # is byte-identical — nothing is at risk). Adoption writes no sentinel
35
+ # here; the normal publish path below does, and the cp is a content no-op.
36
+ # An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
37
+ # still refused — that is the guard's real job.
38
+ def self.guard_clobber(source, target, store_root)
32
39
  return unless File.exist?(target) || File.symlink?(target)
33
40
  return if managed?(target, store_root)
41
+ return if adoptable?(source, target)
34
42
 
35
43
  raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
36
44
  end
37
45
 
46
+ def self.adoptable?(source, target)
47
+ !File.symlink?(target) && File.file?(target) && FileUtils.identical?(source, target)
48
+ end
49
+
38
50
  def self.managed?(target, store_root)
39
51
  File.exist?(Textus::Ports::SentinelStore.new.sentinel_path(target, store_root))
40
52
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.40.0"
2
+ VERSION = "0.42.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -0,0 +1,42 @@
1
+ module Textus
2
+ module Write
3
+ # Single home for the fetch lifecycle event vocabulary (ADR 0048 D5). Both
4
+ # FetchWorker (synchronous semantics) and FetchOrchestrator (async policy)
5
+ # emit through this seam so the event names and payload shapes live in one
6
+ # place with one derived hook context.
7
+ class FetchEvents
8
+ def self.from(container:, call:)
9
+ new(
10
+ events: container.events,
11
+ hook_context: Textus::Hooks::Context.for(container: container, call: call),
12
+ )
13
+ end
14
+
15
+ def initialize(events:, hook_context:)
16
+ @events = events
17
+ @hook_context = hook_context
18
+ end
19
+
20
+ def started(key, mode: :sync)
21
+ @events.publish(:fetch_started, ctx: @hook_context, key: key, mode: mode)
22
+ end
23
+
24
+ def failed(key, error)
25
+ @events.publish(:fetch_failed, ctx: @hook_context, key: key,
26
+ error_class: error.class.name, error_message: error.message)
27
+ end
28
+
29
+ def fetched(key, envelope, change)
30
+ return if change == :unchanged
31
+
32
+ @events.publish(:entry_fetched, ctx: @hook_context, key: key, envelope: envelope, change: change)
33
+ end
34
+
35
+ def backgrounded(key, started_at:, budget_ms:)
36
+ payload = { key: key, started_at: started_at, budget_ms: budget_ms }
37
+ payload[:ctx] = @hook_context if @hook_context
38
+ @events.publish(:fetch_backgrounded, **payload)
39
+ end
40
+ end
41
+ end
42
+ end