textus 0.39.1 → 0.40.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/SPEC.md +15 -5
- data/lib/textus/boot.rb +2 -2
- data/lib/textus/cli/verb/build.rb +5 -1
- data/lib/textus/cli/verb/hooks.rb +3 -3
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +35 -0
- data/lib/textus/doctor.rb +1 -0
- data/lib/textus/hooks/catalog.rb +36 -0
- data/lib/textus/hooks/event_bus.rb +2 -19
- data/lib/textus/hooks/loader.rb +1 -1
- data/lib/textus/hooks/rpc_registry.rb +3 -11
- data/lib/textus/manifest/capabilities.rb +3 -3
- data/lib/textus/manifest/entry/nested.rb +48 -8
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/publish_each.rb +39 -1
- data/lib/textus/manifest/policy.rb +7 -0
- data/lib/textus/manifest/resolver.rb +15 -2
- data/lib/textus/manifest/schema.rb +55 -0
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/fetch/detached.rb +11 -1
- data/lib/textus/ports/publisher.rb +10 -0
- data/lib/textus/ports/sentinel_store.rb +15 -0
- data/lib/textus/role.rb +18 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/publish.rb +6 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96c684d8a670835fb687a321abb85838ec831e95e8672b7f03453266e2224b65
|
|
4
|
+
data.tar.gz: d9332836814f977824413a7bc379666cfd830f182d2c44a9ed5a71d1e7336513
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cbfdd6527a26f2e8fb97cdde2cc99c5df4d36839af1f426bf52156335187d4b48ecbe45af1b271031861bc225d100676522082da6f74c171555bd5cf7bd0b227
|
|
7
|
+
data.tar.gz: 38ac5fff2e9209c8bbbc15745a1ddbd840736ddcc23363c6a61099926b79807a9ccb49630a98b69f6ed41cf2a15d3272bd586f6bc749ea381e4d24158eeb597a
|
data/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,29 @@ tracks both additive improvements and breaking protocol bumps independently.
|
|
|
11
11
|
|
|
12
12
|
## Unreleased
|
|
13
13
|
|
|
14
|
+
## 0.40.0 — 2026-06-02 — `publish_each` owns multi-file leaf subtrees ([ADR 0046](docs/architecture/decisions/0046-publish-leaf-subtrees.md))
|
|
15
|
+
|
|
16
|
+
No `textus/3` wire-format change — publish is repo-local materialization. The
|
|
17
|
+
publish *unit* is derived from the entry's existing `index_filename`, not a new
|
|
18
|
+
manifest key (SPEC §4, §5.3).
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`publish_each` copies a leaf's whole subtree when the entry declares `index_filename` ([ADR 0046](docs/architecture/decisions/0046-publish-leaf-subtrees.md)).** textus can now own a prose-heavy, multi-file artifact (e.g. a Claude Code Agent Skill: `SKILL.md` + `commands.md` + `references/*`) as a single addressable unit. An entry *without* `index_filename` still publishes one file per leaf (unchanged); an entry *with* `index_filename` treats each leaf as a directory and copies the whole subtree — ignore-filtered (ADR 0042), preserving in-leaf layout, one sentinel per file. Siblings ride along at publish time only: they are never enumerated, addressable, or proposable (the key↔path bijection is preserved). On rebuild, managed orphans under a leaf are pruned (file + sentinel); **unmanaged files are never deleted**. The build envelope grows a `pruned` array.
|
|
23
|
+
- **`doctor` flags orphaned publish targets.** A new `orphaned_publish_targets` check reports a published file whose recorded source no longer exists in the store (a renamed or removed whole leaf) — drift that per-entry `build` won't revisit.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **BREAKING (pre-1.0): directory-leaf `publish_each` semantics ([ADR 0046](docs/architecture/decisions/0046-publish-leaf-subtrees.md)).** An entry with `index_filename` + `publish_each` whose leaf directories contain siblings previously published *only the index file*; it now publishes the whole subtree. This breaks no correct usage — it is either the dropped-siblings defect this fixes, or a template that named a file rather than a directory, which the validator now **rejects loudly** at manifest load. **Migration:** a directory-leaf `publish_each` template must name the target *directory* — drop a trailing index filename (`.../{leaf}/SKILL.md` → `.../{leaf}`) or any file extension (`.../{leaf}.md` → `.../{leaf}`), and use `{leaf}` or `{key}` (not `{basename}`/`{ext}`, which are file-only).
|
|
28
|
+
- **Role names are now a closed set `{human, agent, automation}` ([ADR 0045](docs/architecture/decisions/0045-close-role-name-set.md)).** A manifest declaring any other role name is rejected at load with `unknown role name '<x>' (allowed: human, agent, automation)`. `Role::NAMES` is the single source of truth; each role's `can:` capabilities remain fully tunable. Principal multiplicity moves to the `owner:` field (`owner: human:patrick`). Stricter `textus/3` validation in `Schema.validate_roles!` — no protocol bump; all shipped manifests already comply.
|
|
29
|
+
- **Hook event tables consolidated into `Textus::Hooks::Catalog` (single source
|
|
30
|
+
of truth).** `EventBus` and `RpcRegistry` no longer keep their own event
|
|
31
|
+
tables; both registries, the Loader DSL router, and internal consumers read
|
|
32
|
+
`Catalog::PUBSUB` / `Catalog::RPC` directly. This removes the drift-prone
|
|
33
|
+
`EventBus::RPC_EVENTS` literal that could silently fall out of sync with
|
|
34
|
+
`RpcRegistry`'s events and weaken the cross-registry guards. Internal
|
|
35
|
+
refactor — no `textus/3` wire-format change.
|
|
36
|
+
|
|
14
37
|
## 0.39.1 — 2026-06-01 — Feed ergonomics: `feeds.machine` env snapshot + intake cookbook ([ADR 0043](docs/architecture/decisions/0043-feed-ergonomics-without-breaking-core-purity.md))
|
|
15
38
|
|
|
16
39
|
No `textus/3` wire-format change. `textus init` scaffolds an additional
|
data/SPEC.md
CHANGED
|
@@ -242,7 +242,7 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
|
|
|
242
242
|
|
|
243
243
|
A file at `.textus/zones/skills/ask/SKILL.md` enumerates as `skills.ask`; `.textus/zones/skills/ask/references/algorithm.md` is not enumerated. Resolving `skills.ask` returns the `SKILL.md` path. `index_filename:` requires `nested: true`; the value must be a bare basename (no slashes).
|
|
244
244
|
|
|
245
|
-
**Per-leaf publishing (`publish_each:`).** A nested manifest entry MAY declare `publish_each:` to
|
|
245
|
+
**Per-leaf publishing (`publish_each:`).** A nested manifest entry MAY declare `publish_each:` to copy every leaf to a templated repo-relative path. `publish_each:` and `publish_to:` are mutually exclusive on the same entry, and `publish_each:` requires `nested: true`. The template substitutes these variables (using `{name}` syntax):
|
|
246
246
|
|
|
247
247
|
| Variable | Value |
|
|
248
248
|
|--------------|----------------------------------------------------------------------------------------|
|
|
@@ -251,7 +251,15 @@ A file at `.textus/zones/skills/ask/SKILL.md` enumerates as `skills.ask`; `.text
|
|
|
251
251
|
| `{key}` | Full dotted key. |
|
|
252
252
|
| `{ext}` | Primary extension for the entry's format, without the leading dot (`md`/`json`/`yaml`/`txt`). |
|
|
253
253
|
|
|
254
|
-
|
|
254
|
+
The publish *unit* is derived from the entry's shape, which `index_filename:` already declares (ADR 0046) — there is no separate tree-vs-file key:
|
|
255
|
+
|
|
256
|
+
- **Entry *without* `index_filename`** — a leaf is a *file*. `publish_each:` names a file target and one file is byte-copied per leaf.
|
|
257
|
+
- **Entry *with* `index_filename`** — a leaf is a *directory*. `publish_each:` names a directory target, and the **whole leaf subtree** is copied: every file under the leaf directory (the index plus its siblings) is byte-copied to `<target_dir>/<path-relative-to-leaf>`, preserving in-leaf layout. Siblings are opaque payload — they are never enumerated, addressable, or proposable; only the index is a key. The entry's `ignore:` globs (§4, ADR 0042) filter the copied set. Each copied file gets its own sentinel. On rebuild, files under the target that are textus-managed but no longer produced by the current source are **pruned** (file + sentinel); files with no sentinel are never deleted.
|
|
258
|
+
|
|
259
|
+
Validation at manifest load: any unknown variable raises `UsageError`; a computed target outside the repo root is refused at build time with `PublishError`. The discriminator requirement depends on the leaf shape:
|
|
260
|
+
|
|
261
|
+
- **File-leaf** templates MUST reference at least one of `{leaf}`, `{basename}`, `{key}` (otherwise every leaf would clobber the same target).
|
|
262
|
+
- **Directory-leaf** templates (entry has `index_filename`) MUST reference `{leaf}` or `{key}`, and MUST NOT use `{basename}` or `{ext}` (file-only). Because the target is a directory, the template's final segment MUST NOT be the `index_filename` (the `.../{leaf}/SKILL.md` footgun — it would copy the subtree into a directory literally named `SKILL.md/`) and MUST NOT carry a file extension (e.g. `.../{leaf}.md`); both are rejected at load with a fix-the-template message.
|
|
255
263
|
|
|
256
264
|
```yaml
|
|
257
265
|
- key: working.skills
|
|
@@ -259,10 +267,12 @@ Validation at manifest load: any unknown variable raises `UsageError`; the templ
|
|
|
259
267
|
zone: working
|
|
260
268
|
schema: skill
|
|
261
269
|
nested: true
|
|
262
|
-
|
|
270
|
+
index_filename: SKILL.md
|
|
271
|
+
publish_each: "skills/{leaf}"
|
|
272
|
+
ignore: ["*.tmp", ".DS_Store"]
|
|
263
273
|
```
|
|
264
274
|
|
|
265
|
-
A leaf at `working.skills.
|
|
275
|
+
A leaf at `working.skills.voice-writer` (a directory `.textus/zones/working/skills/voice-writer/` containing `SKILL.md`, `commands.md`, and `references/*`) publishes its whole subtree under `skills/voice-writer/`, preserving layout.
|
|
266
276
|
|
|
267
277
|
**`inject_boot:`.** A derived entry with a `template:` MAY declare `inject_boot: true`. When `textus build` materializes the entry, it merges the `textus boot` envelope (§9) into the projection data under the key `boot`, so the template can render orientation content (zones, write flows, CLI catalog) alongside its projected rows. The flag is rejected at manifest load on (a) non-derived entries or (b) derived entries without a `template:` — agents reading the rendered file should be able to trust the preamble was produced by the same source of truth `textus boot` exposes.
|
|
268
278
|
|
|
@@ -468,7 +478,7 @@ When the entry is recomputed, textus copies the in-store file byte-for-byte to e
|
|
|
468
478
|
|
|
469
479
|
A sentinel is written for each published file at `<store_root>/sentinels/<target-relative-to-repo>.textus-managed.json`, recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store rather than beside the consumer file so target directories stay clean. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing or marked as managed. Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
|
|
470
480
|
|
|
471
|
-
**Per-leaf publishing.** A nested entry MAY declare `publish_each:` instead of `publish_to:` (see §4). When the build runs, every leaf reachable under the nested entry is
|
|
481
|
+
**Per-leaf publishing.** A nested entry MAY declare `publish_each:` instead of `publish_to:` (see §4). When the build runs, every leaf reachable under the nested entry is copied to the path produced by substituting `{leaf}` / `{basename}` / `{key}` / `{ext}` in the template, with a sentinel written under `<store_root>/sentinels/` at the mirrored target path. The publish unit follows the entry's `index_filename` (ADR 0046): a file-leaf entry byte-copies one file per leaf; a directory-leaf entry (with `index_filename`) byte-copies the leaf's whole subtree — one row and one sentinel **per file** — applying the entry's `ignore:` filter, and prunes textus-managed files under the target that the current source no longer produces (never touching unmanaged files). The build envelope grows a `published_leaves` array — one row per published file, with `key`, `source`, and `target` — alongside the existing `built` array, plus a `pruned` array listing any orphaned managed files removed on this build. Targets that would resolve outside the repo root are refused.
|
|
472
482
|
|
|
473
483
|
### 5.4 Intake (declared, fetched via registered intake handler)
|
|
474
484
|
|
data/lib/textus/boot.rb
CHANGED
|
@@ -236,10 +236,10 @@ module Textus
|
|
|
236
236
|
|
|
237
237
|
def self.hooks_for_container_internal(rpc:, events:)
|
|
238
238
|
sections = {}
|
|
239
|
-
Hooks::
|
|
239
|
+
Hooks::Catalog::RPC.each_key do |event|
|
|
240
240
|
sections[event.to_s] = rpc.names(event).map(&:to_s).sort
|
|
241
241
|
end
|
|
242
|
-
Hooks::
|
|
242
|
+
Hooks::Catalog::PUBSUB.each_key do |event|
|
|
243
243
|
sections[event.to_s] = events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
|
|
244
244
|
end
|
|
245
245
|
sections
|
|
@@ -7,8 +7,12 @@ module Textus
|
|
|
7
7
|
option :prefix, "--prefix=K"
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
|
+
role = store.manifest.policy.actor_for("build") or
|
|
11
|
+
raise UsageError.new(
|
|
12
|
+
"no role holds the 'build' capability",
|
|
13
|
+
hint: "declare a role with `can: [build]` in .textus/manifest.yaml",
|
|
14
|
+
)
|
|
10
15
|
Textus::Ports::BuildLock.with(root: store.root) do
|
|
11
|
-
role = store.manifest.policy.roles_with_capability("build").first || "automation"
|
|
12
16
|
ops = store.as(role)
|
|
13
17
|
result = ops.publish(prefix: prefix)
|
|
14
18
|
emit(result)
|
|
@@ -16,12 +16,12 @@ module Textus
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
rows = []
|
|
19
|
-
Textus::Hooks::
|
|
19
|
+
Textus::Hooks::Catalog::RPC.each_key do |event|
|
|
20
20
|
store.rpc.names(event).each do |name|
|
|
21
21
|
rows << { "event" => event.to_s, "mode" => "rpc", "name" => name.to_s }
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
|
-
Textus::Hooks::
|
|
24
|
+
Textus::Hooks::Catalog::PUBSUB.each_key do |event|
|
|
25
25
|
store.events.pubsub_handlers(event).each do |h|
|
|
26
26
|
row = { "event" => event.to_s, "mode" => "pubsub", "name" => h[:name].to_s }
|
|
27
27
|
row["keys"] = Array(h[:keys]) if h[:keys]
|
|
@@ -35,7 +35,7 @@ module Textus
|
|
|
35
35
|
|
|
36
36
|
rows << {
|
|
37
37
|
"event" => evt.to_s, "mode" => "manifest", "exec" => defn["exec"],
|
|
38
|
-
"key" => e.key, "as" => defn["as"] ||
|
|
38
|
+
"key" => e.key, "as" => defn["as"] || Textus::Role::AUTOMATION
|
|
39
39
|
}
|
|
40
40
|
end
|
|
41
41
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# Flags published files whose recorded source no longer exists in the
|
|
5
|
+
# store. Per-leaf prune (ADR 0046) reconciles within a still-present
|
|
6
|
+
# leaf; a renamed or removed *whole* leaf orphans its entire target
|
|
7
|
+
# directory, which a per-entry build won't revisit. This check catches
|
|
8
|
+
# that drift without making `build` scan globally.
|
|
9
|
+
class OrphanedPublishTargets < Check
|
|
10
|
+
def call
|
|
11
|
+
sdir = File.join(root, Textus::Ports::SentinelStore::DIR)
|
|
12
|
+
return [] unless File.directory?(sdir)
|
|
13
|
+
|
|
14
|
+
repo_root = File.dirname(root)
|
|
15
|
+
store = Textus::Ports::SentinelStore.new
|
|
16
|
+
glob = File.join(sdir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")
|
|
17
|
+
Dir.glob(glob).filter_map do |spath|
|
|
18
|
+
sentinel = store.load(spath, repo_root)
|
|
19
|
+
next nil if sentinel.nil? || sentinel.source.nil?
|
|
20
|
+
next nil if File.exist?(sentinel.source)
|
|
21
|
+
|
|
22
|
+
{
|
|
23
|
+
"code" => "publish.orphaned_target",
|
|
24
|
+
"level" => "warning",
|
|
25
|
+
"subject" => sentinel.target,
|
|
26
|
+
"message" => "published file #{sentinel.target} has no source in the store " \
|
|
27
|
+
"(recorded source #{sentinel.source} is gone) — likely a renamed or removed leaf",
|
|
28
|
+
"fix" => "remove the stale copy and its sentinel: rm '#{sentinel.target}' '#{spath}'",
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
# The single source of truth for hook event names and their required
|
|
6
|
+
# kwargs. EventBus, RpcRegistry, and the Loader DSL router all read these
|
|
7
|
+
# tables directly — the registries do not keep their own copies. Catalog
|
|
8
|
+
# references no other constant, so it has no load-order cycle, which is
|
|
9
|
+
# what removed the previous drift hazard (EventBus held a hard-coded
|
|
10
|
+
# `RPC_EVENTS` list that could fall out of sync with RpcRegistry's table).
|
|
11
|
+
module Catalog
|
|
12
|
+
# Pub-sub events: 0..N handlers, fire-and-forget, receive `ctx:`.
|
|
13
|
+
PUBSUB = {
|
|
14
|
+
entry_put: %i[ctx key envelope],
|
|
15
|
+
entry_deleted: %i[ctx key],
|
|
16
|
+
entry_fetched: %i[ctx key envelope change],
|
|
17
|
+
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
18
|
+
build_completed: %i[ctx key envelope sources],
|
|
19
|
+
proposal_accepted: %i[ctx key target_key],
|
|
20
|
+
proposal_rejected: %i[ctx key target_key],
|
|
21
|
+
file_published: %i[ctx key envelope source target],
|
|
22
|
+
store_loaded: %i[ctx],
|
|
23
|
+
fetch_started: %i[ctx key mode],
|
|
24
|
+
fetch_failed: %i[ctx key error_class error_message],
|
|
25
|
+
fetch_backgrounded: %i[ctx key started_at budget_ms],
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# RPC events: single handler, return value matters, receive `caps:`.
|
|
29
|
+
RPC = {
|
|
30
|
+
resolve_intake: %i[caps config args],
|
|
31
|
+
transform_rows: %i[caps rows config],
|
|
32
|
+
validate: %i[caps],
|
|
33
|
+
}.freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -7,23 +7,6 @@ module Textus
|
|
|
7
7
|
|
|
8
8
|
class HookTimeout < StandardError; end
|
|
9
9
|
|
|
10
|
-
EVENTS = {
|
|
11
|
-
entry_put: %i[ctx key envelope],
|
|
12
|
-
entry_deleted: %i[ctx key],
|
|
13
|
-
entry_fetched: %i[ctx key envelope change],
|
|
14
|
-
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
15
|
-
build_completed: %i[ctx key envelope sources],
|
|
16
|
-
proposal_accepted: %i[ctx key target_key],
|
|
17
|
-
proposal_rejected: %i[ctx key target_key],
|
|
18
|
-
file_published: %i[ctx key envelope source target],
|
|
19
|
-
store_loaded: %i[ctx],
|
|
20
|
-
fetch_started: %i[ctx key mode],
|
|
21
|
-
fetch_failed: %i[ctx key error_class error_message],
|
|
22
|
-
fetch_backgrounded: %i[ctx key started_at budget_ms],
|
|
23
|
-
}.freeze
|
|
24
|
-
|
|
25
|
-
RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
|
|
26
|
-
|
|
27
10
|
def initialize(error_log: ErrorLog.new)
|
|
28
11
|
@pubsub = Hash.new { |h, k| h[k] = [] }
|
|
29
12
|
@error_handlers = []
|
|
@@ -36,9 +19,9 @@ module Textus
|
|
|
36
19
|
|
|
37
20
|
def register(event, name, keys: nil, &blk)
|
|
38
21
|
event_sym = event.to_sym
|
|
39
|
-
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if
|
|
22
|
+
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if Catalog::RPC.key?(event_sym)
|
|
40
23
|
|
|
41
|
-
required =
|
|
24
|
+
required = Catalog::PUBSUB[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
25
|
sig = Signature.new(blk)
|
|
43
26
|
missing = sig.missing(required)
|
|
44
27
|
if missing.any?
|
data/lib/textus/hooks/loader.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Textus
|
|
|
12
12
|
# Pubsub registration — delegates to EventBus.
|
|
13
13
|
# Also handles RPC event names by delegating to RpcRegistry.
|
|
14
14
|
def on(event, name, keys: nil, &)
|
|
15
|
-
if Hooks::
|
|
15
|
+
if Hooks::Catalog::RPC.key?(event.to_sym)
|
|
16
16
|
@rpc.register(event, name, &)
|
|
17
17
|
else
|
|
18
18
|
@events.register(event, name, keys: keys, &)
|
|
@@ -3,23 +3,15 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Hooks
|
|
5
5
|
class RpcRegistry
|
|
6
|
-
EVENTS = {
|
|
7
|
-
resolve_intake: %i[caps config args],
|
|
8
|
-
transform_rows: %i[caps rows config],
|
|
9
|
-
validate: %i[caps],
|
|
10
|
-
}.freeze
|
|
11
|
-
|
|
12
|
-
PUBSUB_EVENTS = EventBus::EVENTS.keys.freeze
|
|
13
|
-
|
|
14
6
|
def initialize
|
|
15
7
|
@table = Hash.new { |h, k| h[k] = {} }
|
|
16
8
|
end
|
|
17
9
|
|
|
18
10
|
def register(event, name, &blk)
|
|
19
11
|
event_sym = event.to_sym
|
|
20
|
-
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if
|
|
12
|
+
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if Catalog::PUBSUB.key?(event_sym)
|
|
21
13
|
|
|
22
|
-
required =
|
|
14
|
+
required = Catalog::RPC[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
|
|
23
15
|
sig = Signature.new(blk)
|
|
24
16
|
missing = sig.missing(required)
|
|
25
17
|
raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
|
|
@@ -38,7 +30,7 @@ module Textus
|
|
|
38
30
|
|
|
39
31
|
# Invoke a registered callable, injecting `caps:` only if the callable
|
|
40
32
|
# declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
|
|
41
|
-
# `
|
|
33
|
+
# `store:`) are rejected at registration time, not here.
|
|
42
34
|
def invoke(event, name, caps:, **other)
|
|
43
35
|
blk = callable(event, name)
|
|
44
36
|
sig = Signature.new(blk)
|
|
@@ -11,9 +11,9 @@ module Textus
|
|
|
11
11
|
# declares a `kind: workspace` zone is therefore rejected at load (no
|
|
12
12
|
# `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
|
|
13
13
|
DEFAULT_MAPPING = {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
Textus::Role::HUMAN => %w[author propose].freeze,
|
|
15
|
+
Textus::Role::AGENT => %w[propose].freeze,
|
|
16
|
+
Textus::Role::AUTOMATION => %w[fetch build].freeze,
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
19
19
|
# Returns { role_name => [verbs] }. When `roles:` is declared we use
|
|
@@ -41,6 +41,7 @@ module Textus
|
|
|
41
41
|
return nil if @publish_each.nil?
|
|
42
42
|
|
|
43
43
|
leaves = []
|
|
44
|
+
pruned = [] # accumulates orphans removed by prune_orphans below
|
|
44
45
|
pctx.manifest.resolver.enumerate(prefix: @key).each do |row|
|
|
45
46
|
next unless row[:manifest_entry].equal?(self)
|
|
46
47
|
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
@@ -53,18 +54,57 @@ module Textus
|
|
|
53
54
|
)
|
|
54
55
|
end
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
envelope: pctx.reader.call(row[:key]),
|
|
60
|
-
source: row[:path],
|
|
61
|
-
target: target_abs)
|
|
62
|
-
leaves << { "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
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"] } }
|
|
63
60
|
end
|
|
64
61
|
|
|
65
|
-
{ kind: :leaves, value: leaves }
|
|
62
|
+
{ kind: :leaves, value: leaves, pruned: pruned }
|
|
66
63
|
end
|
|
67
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
|
+
|
|
68
108
|
KIND = :nested
|
|
69
109
|
|
|
70
110
|
def self.from_raw(common, raw)
|
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module Events
|
|
6
6
|
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
|
-
pubsub_events = Textus::Hooks::
|
|
7
|
+
pubsub_events = Textus::Hooks::Catalog::PUBSUB.keys
|
|
8
8
|
events = entry.events
|
|
9
9
|
events.each_key do |evt|
|
|
10
10
|
next if pubsub_events.include?(evt.to_sym)
|
|
@@ -27,13 +27,51 @@ module Textus
|
|
|
27
27
|
)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
validate_discriminator(entry, used_vars, publish_each)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate_discriminator(entry, used_vars, publish_each)
|
|
34
|
+
if entry.index_filename
|
|
35
|
+
forbidden = used_vars & %w[basename ext]
|
|
36
|
+
unless forbidden.empty?
|
|
37
|
+
raise UsageError.new(
|
|
38
|
+
"entry '#{entry.key}': publish_each names a directory " \
|
|
39
|
+
"(index_filename: '#{entry.index_filename}'); {basename}/{ext} are file-only — " \
|
|
40
|
+
"use {leaf} or {key}.",
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
last_segment = publish_each.sub(%r{/\z}, "").split("/").last
|
|
44
|
+
if last_segment == entry.index_filename
|
|
45
|
+
raise UsageError.new(
|
|
46
|
+
"entry '#{entry.key}': directory-leaf publish_each must name the target DIRECTORY, " \
|
|
47
|
+
"not the index file — drop the trailing '/#{entry.index_filename}' " \
|
|
48
|
+
"(the whole leaf subtree is copied into the named directory).",
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
ext = File.extname(last_segment)
|
|
52
|
+
unless ext.empty?
|
|
53
|
+
raise UsageError.new(
|
|
54
|
+
"entry '#{entry.key}': directory-leaf publish_each names a DIRECTORY target, but its " \
|
|
55
|
+
"final segment '#{last_segment}' looks like a file (extension '#{ext}') — " \
|
|
56
|
+
"drop the extension (the whole leaf subtree is copied into the named directory).",
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
return if used_vars.intersect?(%w[leaf key])
|
|
60
|
+
|
|
61
|
+
raise UsageError.new(
|
|
62
|
+
"entry '#{entry.key}': directory-leaf publish_each must reference {leaf} or {key} " \
|
|
63
|
+
"(else every leaf would clobber the same directory).",
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return if used_vars.intersect?(REQUIRED_DISCRIMINATOR_VARS)
|
|
31
68
|
|
|
32
69
|
raise UsageError.new(
|
|
33
70
|
"entry '#{entry.key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
|
|
34
71
|
"(else every leaf would clobber the same target).",
|
|
35
72
|
)
|
|
36
73
|
end
|
|
74
|
+
private_class_method :validate_discriminator
|
|
37
75
|
end
|
|
38
76
|
end
|
|
39
77
|
end
|
|
@@ -34,6 +34,13 @@ module Textus
|
|
|
34
34
|
(proposers - roles_with_capability("author")).first || proposers.first
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# The role textus acts AS for a system-initiated operation requiring
|
|
38
|
+
# `verb` (no human passed --as). Capability-derived — a role name that
|
|
39
|
+
# exists in the manifest, or nil. Never a hardcoded literal (ADR 0044).
|
|
40
|
+
def actor_for(verb)
|
|
41
|
+
roles_with_capability(verb).first
|
|
42
|
+
end
|
|
43
|
+
|
|
37
44
|
# The roles authorized to write `zone_name`: those holding the verb its
|
|
38
45
|
# kind requires. Raises on an undeclared zone.
|
|
39
46
|
def zone_writers(zone_name)
|
|
@@ -72,8 +72,21 @@ module Textus
|
|
|
72
72
|
return [] unless File.directory?(base)
|
|
73
73
|
|
|
74
74
|
entry_index_filename = entry.index_filename
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
unless entry_index_filename
|
|
76
|
+
return Dir.glob(File.join(base, nested_glob(entry.format)))
|
|
77
|
+
.filter_map { |path| nested_row_for(entry, base, path) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
claimed = []
|
|
81
|
+
Dir.glob(File.join(base, "**", entry_index_filename))
|
|
82
|
+
.sort_by { |path| path.count("/") }
|
|
83
|
+
.filter_map do |path|
|
|
84
|
+
leaf_dir = File.dirname(path)
|
|
85
|
+
next nil if claimed.any? { |d| leaf_dir.start_with?("#{d}/") }
|
|
86
|
+
|
|
87
|
+
claimed << leaf_dir
|
|
88
|
+
nested_row_for(entry, base, path)
|
|
89
|
+
end
|
|
77
90
|
end
|
|
78
91
|
|
|
79
92
|
def nested_row_for(entry, base, path)
|
|
@@ -35,6 +35,13 @@ module Textus
|
|
|
35
35
|
RETENTION_KEYS = %w[expire_after archive_after].freeze
|
|
36
36
|
AUDIT_KEYS = %w[max_size keep].freeze
|
|
37
37
|
|
|
38
|
+
# Syntactic shape of an `owner:` subject token (the `patrick` in
|
|
39
|
+
# `human:patrick`) — the subject half of the owner-validation rule below.
|
|
40
|
+
# Role supplies the archetype set (Role::NAMES); this pattern is the
|
|
41
|
+
# owner-specific part, so it lives with the rule that composes them
|
|
42
|
+
# (ADR 0045 D1). Acting-role *names* are gated by Role::NAMES, not a regex.
|
|
43
|
+
OWNER_SUBJECT_PATTERN = /\A[a-z][a-z0-9_-]*\z/
|
|
44
|
+
|
|
38
45
|
def self.validate!(raw)
|
|
39
46
|
raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
|
|
40
47
|
|
|
@@ -42,6 +49,7 @@ module Textus
|
|
|
42
49
|
validate_roles!(raw["roles"])
|
|
43
50
|
validate_zones!(raw["zones"])
|
|
44
51
|
validate_entries!(raw["entries"])
|
|
52
|
+
validate_owners!(raw["zones"], raw["entries"])
|
|
45
53
|
validate_rules!(raw["rules"])
|
|
46
54
|
walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
|
|
47
55
|
validate_single_queue!(raw)
|
|
@@ -91,6 +99,12 @@ module Textus
|
|
|
91
99
|
path = "$.roles[#{i}]"
|
|
92
100
|
walk(r, ROLE_KEYS, path)
|
|
93
101
|
name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
|
|
102
|
+
unless Textus::Role::NAMES.include?(name)
|
|
103
|
+
raise BadManifest.new(
|
|
104
|
+
"unknown role name '#{name}' at '#{path}' " \
|
|
105
|
+
"(allowed: #{Textus::Role::NAMES.join(", ")})",
|
|
106
|
+
)
|
|
107
|
+
end
|
|
94
108
|
Array(r["can"]).each do |verb|
|
|
95
109
|
next if CAPABILITIES.include?(verb)
|
|
96
110
|
|
|
@@ -109,6 +123,47 @@ module Textus
|
|
|
109
123
|
)
|
|
110
124
|
end
|
|
111
125
|
|
|
126
|
+
# Owners are validated against the SAME closed archetype set as role names
|
|
127
|
+
# (ADR 0045 D1) so attribution can't bypass the closed-name guarantee.
|
|
128
|
+
# Applies to both zone owners and entry owners; owner is optional, so a
|
|
129
|
+
# nil owner is not an error.
|
|
130
|
+
def self.validate_owners!(zones, entries)
|
|
131
|
+
Array(zones).each_with_index do |z, i|
|
|
132
|
+
check_owner!(z["owner"], "$.zones[#{i}]")
|
|
133
|
+
end
|
|
134
|
+
Array(entries).each_with_index do |e, i|
|
|
135
|
+
check_owner!(e["owner"], "$.entries[#{i}]")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.check_owner!(owner, path)
|
|
140
|
+
return if owner.nil?
|
|
141
|
+
return if valid_owner?(owner)
|
|
142
|
+
|
|
143
|
+
raise BadManifest.new(
|
|
144
|
+
"invalid owner '#{owner}' at '#{path}' " \
|
|
145
|
+
"(expected <archetype> or <archetype>:<subject>, " \
|
|
146
|
+
"archetype one of: #{Textus::Role::NAMES.join(", ")})",
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The owner-validation rule: an `owner:` token is either a bare archetype
|
|
151
|
+
# (`agent`) or `<archetype>:<subject>` (`human:patrick`). The archetype is
|
|
152
|
+
# gated against the closed Role::NAMES set (so attribution can't smuggle in
|
|
153
|
+
# a name the role side rejects, ADR 0045 D1); the subject is the free-form
|
|
154
|
+
# principal, validated by OWNER_SUBJECT_PATTERN. Split on the FIRST ':'
|
|
155
|
+
# only — a subject may not itself contain ':' (the pattern excludes it), so
|
|
156
|
+
# `human:a:b` is rejected.
|
|
157
|
+
def self.valid_owner?(token)
|
|
158
|
+
return false unless token.is_a?(String) && !token.empty?
|
|
159
|
+
|
|
160
|
+
archetype, subject = token.split(":", 2)
|
|
161
|
+
return false unless Textus::Role::NAMES.include?(archetype)
|
|
162
|
+
return true if subject.nil?
|
|
163
|
+
|
|
164
|
+
OWNER_SUBJECT_PATTERN.match?(subject)
|
|
165
|
+
end
|
|
166
|
+
|
|
112
167
|
def self.validate_fetch_timeout!(value, path)
|
|
113
168
|
return if value.nil?
|
|
114
169
|
return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
|
|
@@ -33,7 +33,7 @@ module Textus
|
|
|
33
33
|
extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
|
|
34
34
|
extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
|
|
35
35
|
@audit_log.append(
|
|
36
|
-
role:
|
|
36
|
+
role: Textus::Role::AUTOMATION, verb: "event_error", key: key,
|
|
37
37
|
etag_before: nil, etag_after: nil, extras: extras
|
|
38
38
|
)
|
|
39
39
|
end
|
|
@@ -8,6 +8,10 @@ module Textus
|
|
|
8
8
|
Process.respond_to?(:fork)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
def acting_role(store)
|
|
12
|
+
store.manifest.policy.actor_for("fetch")
|
|
13
|
+
end
|
|
14
|
+
|
|
11
15
|
def spawn(store_root:, key:)
|
|
12
16
|
return nil unless supported?
|
|
13
17
|
|
|
@@ -21,7 +25,13 @@ module Textus
|
|
|
21
25
|
|
|
22
26
|
begin
|
|
23
27
|
store = Textus::Store.new(store_root)
|
|
24
|
-
|
|
28
|
+
# No fetch-holder configured — exit the child cleanly. In practice
|
|
29
|
+
# this is unreachable: the background fork only happens after a
|
|
30
|
+
# foreground fetch was already authorized (so a fetch-holder
|
|
31
|
+
# exists). Config-time detection is doctor's job (ADR 0044 Q2).
|
|
32
|
+
role = acting_role(store)
|
|
33
|
+
exit(0) unless role
|
|
34
|
+
store.as(role).fetch(key)
|
|
25
35
|
rescue StandardError
|
|
26
36
|
# Already logged via :fetch_failed; exit cleanly.
|
|
27
37
|
ensure
|
|
@@ -18,6 +18,16 @@ module Textus
|
|
|
18
18
|
Textus::Ports::SentinelStore.new.write!(target: target, source: source, store_root: store_root)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
# Removes a previously-published file and its sentinel. No-op unless the
|
|
22
|
+
# target is textus-managed — never deletes an unmanaged file.
|
|
23
|
+
def self.unpublish(target:, store_root:)
|
|
24
|
+
return unless managed?(target, store_root)
|
|
25
|
+
|
|
26
|
+
FileUtils.rm_f(target)
|
|
27
|
+
sentinel = Textus::Ports::SentinelStore.new.sentinel_path(target, store_root)
|
|
28
|
+
FileUtils.rm_f(sentinel)
|
|
29
|
+
end
|
|
30
|
+
|
|
21
31
|
def self.refuse_if_unmanaged(target, store_root)
|
|
22
32
|
return unless File.exist?(target) || File.symlink?(target)
|
|
23
33
|
return if managed?(target, store_root)
|
|
@@ -42,6 +42,21 @@ module Textus
|
|
|
42
42
|
File.join(store_root, DIR, rel + SUFFIX)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Absolute target paths of every sentinel recorded under `target_dir`.
|
|
46
|
+
def targets_under(target_dir, store_root)
|
|
47
|
+
repo_root = File.dirname(store_root)
|
|
48
|
+
rel = relative_to(target_dir, repo_root) or return []
|
|
49
|
+
sdir = File.join(store_root, DIR, rel)
|
|
50
|
+
return [] unless File.directory?(sdir)
|
|
51
|
+
|
|
52
|
+
prefix = File.join(store_root, DIR) + "/"
|
|
53
|
+
Dir.glob(File.join(sdir, "**", "*#{SUFFIX}")).map do |spath|
|
|
54
|
+
# strip the sentinel-store prefix and the .textus-managed.json suffix to recover the repo-relative target path
|
|
55
|
+
trel = spath.delete_prefix(prefix).delete_suffix(SUFFIX)
|
|
56
|
+
File.join(repo_root, trel)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
45
60
|
private
|
|
46
61
|
|
|
47
62
|
def rel_or_abs(path, repo_root)
|
data/lib/textus/role.rb
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Role
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
# The three role archetypes, each string sourced exactly once: human curates
|
|
4
|
+
# canon, agent proposes, automation fetches/builds (explanation/concepts.md).
|
|
5
|
+
# Reference these constants instead of bare literals (ADR 0044).
|
|
6
|
+
HUMAN = "human".freeze
|
|
7
|
+
AGENT = "agent".freeze
|
|
8
|
+
AUTOMATION = "automation".freeze
|
|
9
|
+
|
|
10
|
+
# The closed set of legal role names (ADR 0045), built FROM the archetypes
|
|
11
|
+
# above so it stays the single source of truth — a manifest declaring any
|
|
12
|
+
# other name is rejected at load, and DEFAULT ∈ NAMES holds structurally.
|
|
13
|
+
# Capabilities (`can:`) remain freely tunable per role.
|
|
14
|
+
NAMES = [HUMAN, AGENT, AUTOMATION].freeze
|
|
15
|
+
|
|
16
|
+
# Default acting identity (ADR 0040): a *choice* over the vocabulary, not a
|
|
17
|
+
# new name. CLI callers act as the human; an agent over stdio proposes and
|
|
18
|
+
# does not inherit the human's authority (it defaults to AGENT per transport).
|
|
19
|
+
DEFAULT = HUMAN
|
|
9
20
|
|
|
10
21
|
def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
|
|
11
22
|
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
|
|
12
|
-
raise InvalidRole.new(candidate) unless
|
|
23
|
+
raise InvalidRole.new(candidate) unless NAMES.include?(candidate)
|
|
13
24
|
|
|
14
25
|
candidate
|
|
15
26
|
end
|
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/publish.rb
CHANGED
|
@@ -18,6 +18,7 @@ module Textus
|
|
|
18
18
|
def call(prefix: nil)
|
|
19
19
|
built = []
|
|
20
20
|
leaves = []
|
|
21
|
+
pruned = []
|
|
21
22
|
context = build_context
|
|
22
23
|
|
|
23
24
|
@manifest.data.entries.each do |mentry|
|
|
@@ -27,12 +28,14 @@ module Textus
|
|
|
27
28
|
next if result.nil?
|
|
28
29
|
|
|
29
30
|
case result[:kind]
|
|
30
|
-
when :built
|
|
31
|
-
when :leaves
|
|
31
|
+
when :built then built << result[:value]
|
|
32
|
+
when :leaves
|
|
33
|
+
leaves.concat(result[:value])
|
|
34
|
+
pruned.concat(result[:pruned]) if result[:pruned]
|
|
32
35
|
end
|
|
33
36
|
end
|
|
34
37
|
|
|
35
|
-
{ "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves }
|
|
38
|
+
{ "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves, "pruned" => pruned }
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
private
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: textus
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.40.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -176,6 +176,7 @@ files:
|
|
|
176
176
|
- lib/textus/doctor/check/illegal_keys.rb
|
|
177
177
|
- lib/textus/doctor/check/intake_registration.rb
|
|
178
178
|
- lib/textus/doctor/check/manifest_files.rb
|
|
179
|
+
- lib/textus/doctor/check/orphaned_publish_targets.rb
|
|
179
180
|
- lib/textus/doctor/check/proposal_targets.rb
|
|
180
181
|
- lib/textus/doctor/check/protocol_version.rb
|
|
181
182
|
- lib/textus/doctor/check/rule_ambiguity.rb
|
|
@@ -225,6 +226,7 @@ files:
|
|
|
225
226
|
- lib/textus/errors.rb
|
|
226
227
|
- lib/textus/etag.rb
|
|
227
228
|
- lib/textus/hooks/builtin.rb
|
|
229
|
+
- lib/textus/hooks/catalog.rb
|
|
228
230
|
- lib/textus/hooks/context.rb
|
|
229
231
|
- lib/textus/hooks/error_log.rb
|
|
230
232
|
- lib/textus/hooks/event_bus.rb
|