textus 0.41.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/README.md +2 -2
- data/SPEC.md +3 -37
- data/docs/reference/conventions.md +1 -1
- data/lib/textus/boot.rb +1 -2
- data/lib/textus/manifest/entry/base.rb +0 -1
- data/lib/textus/manifest/entry/nested.rb +2 -4
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +8 -8
- data/lib/textus/manifest/entry/publish/template.rb +5 -11
- data/lib/textus/manifest/entry/publish.rb +18 -8
- data/lib/textus/manifest/entry/validators/publish.rb +5 -5
- data/lib/textus/manifest/schema.rb +14 -1
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/publish.rb +3 -3
- metadata +1 -4
- data/lib/textus/manifest/entry/publish/each.rb +0 -83
- data/lib/textus/manifest/entry/publish/each_dir.rb +0 -74
- data/lib/textus/manifest/entry/publish/each_file.rb +0 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36d711f8c5947548e8f2431cfded1a514596af239b4eb6e91a3d3d0a6f20c83e
|
|
4
|
+
data.tar.gz: 33496ad8d0d862655cddbc47c36fd4c3ccf01f7e1b37dea06ee1fc24062be51c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b5b84af6db5f692db3b27f3b029348ab80d7bc9e05d2ebf74d32b9fc9b29d497fb275b508725b8437549f75bc69c1de01818ccc2d1eee4dc2af79cfa292e6dc
|
|
7
|
+
data.tar.gz: 99f9c63b92f629bd37956a79a303fcc081997d81afe96a85d804d806831388f9d6821b66c449299f82c7f27422e97ada2759518be6062061a45acb8db043d7c9
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,14 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
|
|
|
9
9
|
bump is a breaking change that requires a store migration; the gem version
|
|
10
10
|
tracks both additive improvements and breaking protocol bumps independently.
|
|
11
11
|
|
|
12
|
+
## 0.42.0 — 2026-06-02 — Remove `publish_each`: collapse publish to two modes ([ADR 0051](docs/architecture/decisions/0051-remove-publish-each.md))
|
|
13
|
+
|
|
14
|
+
No `textus/3` wire-format change. **Breaking (pre-1.0):** the `publish_each:` manifest key is removed; a manifest declaring it now fails at load. The publish surface collapses to two modes — `publish_to:` (fixed paths) and `publish_tree:` (whole-subtree mirror) — plus `None`.
|
|
15
|
+
|
|
16
|
+
### Removed
|
|
17
|
+
|
|
18
|
+
- **BREAKING (pre-1.0): the `publish_each:` publish mode is removed ([ADR 0051](docs/architecture/decisions/0051-remove-publish-each.md)).** Both the file-leaf and directory-leaf (`index_filename`-driven) forms of `publish_each` are gone, along with the `Publish::Each` / `EachFile` / `EachDir` modes and their `{leaf}`/`{basename}`/`{key}`/`{ext}` template vocabulary. The mode had zero real-world usage (dogfood, example, and `init` scaffold all publish via `publish_to`), and its one niche — a leaf that is both an addressable key *and* published to a per-leaf templated path — never materialized, including in the native skill-authoring pipeline (ADR 0050), which rides `publish_tree`. A manifest declaring `publish_each:` now **fails at load** with a migration message ("publish_each was removed in 0.42.0 (ADR 0051) — mirror the subtree with publish_tree (and index_filename to keep the index addressable)"). **Migration:** a directory-leaf `publish_each: "skills/{leaf}"` over a `nested` skills entry becomes `publish_tree: "skills"` over the parent entry (layout preserved). `index_filename:` is **kept** — it survives as a pure *enumeration* feature, independent of publish. The ADR 0049 sum type makes re-adding one subtree mode (or lifting `index_filename` ⊥ `publish_tree` so the two compose) a cheap one-arm change if the niche ever appears.
|
|
19
|
+
|
|
12
20
|
## 0.41.0 — 2026-06-02 — `publish_tree` subtree mirror + content-identical publish adoption ([ADR 0047](docs/architecture/decisions/0047-publish-tree-keyless-subtree-mirror.md), [0050](docs/architecture/decisions/0050-native-authoring-and-content-identical-adoption.md))
|
|
13
21
|
|
|
14
22
|
No `textus/3` wire-format change — every change here is repo-local publish behaviour or internal re-layering. Headlines: a key-less `publish_tree:` subtree mirror (ADR 0047), and publish now *adopts* a byte-identical pre-existing target instead of refusing (ADR 0050), so an artifact tree already on disk onboards without a manual delete. Internals were re-cut along the way (fetch subsystem, ADR 0048; publish modes as a sum type, ADR 0049) with no contract change.
|
data/README.md
CHANGED
|
@@ -182,7 +182,7 @@ For a worked store — knowledge entries, a staged proposal, schemas, a template
|
|
|
182
182
|
|
|
183
183
|
## What's shipped
|
|
184
184
|
|
|
185
|
-
- **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; `publish_to:`/`
|
|
185
|
+
- **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; `publish_to:`/`publish_tree:` byte-copy derived files (or a whole subtree) to their consumer paths. ([SPEC §5.2–5.3](SPEC.md))
|
|
186
186
|
- **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
|
|
187
187
|
- **Capability × zone-kind gate.** Writes carry `--as=<role>`; a role may write a zone iff it holds the capability the zone's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `quarantine`→`fetch`, `queue`→`propose`, `derived`→`build`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. ([SPEC §5](SPEC.md))
|
|
188
188
|
- **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, stale keys, pending proposals). ([docs/how-to/agents-mcp.md](docs/how-to/agents-mcp.md))
|
|
@@ -203,7 +203,7 @@ Derived entries declare `compute: { kind: projection, select: ..., pluck: ..., s
|
|
|
203
203
|
|
|
204
204
|
For externally-generated entries, declare `compute: { kind: external, sources: [...] }` — textus tracks the declared sources for staleness; the build automation produces the file.
|
|
205
205
|
|
|
206
|
-
`publish_to: [path]` byte-copies a single derived file to one
|
|
206
|
+
`publish_to: [path]` byte-copies a single derived file to one or more targets. `publish_tree: "dir"` on a nested entry mirrors its whole stored subtree to one target directory, preserving layout (path-driven — no keys or template variables). Sentinels for every published file live under `.textus/sentinels/`. See SPEC §5.2, §5.3, §5.12.
|
|
207
207
|
|
|
208
208
|
## Extension points
|
|
209
209
|
|
data/SPEC.md
CHANGED
|
@@ -240,41 +240,9 @@ For `nested: true`, the recursive glob matches the format's extension (markdown
|
|
|
240
240
|
index_filename: SKILL.md
|
|
241
241
|
```
|
|
242
242
|
|
|
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).
|
|
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). `index_filename:` is a pure *enumeration* feature — it selects which file is the addressable row per directory and is independent of publishing (ADR 0051).
|
|
244
244
|
|
|
245
|
-
**
|
|
246
|
-
|
|
247
|
-
| Variable | Value |
|
|
248
|
-
|--------------|----------------------------------------------------------------------------------------|
|
|
249
|
-
| `{leaf}` | Remaining key segments after the entry prefix, joined with `/`. |
|
|
250
|
-
| `{basename}` | Last segment only. |
|
|
251
|
-
| `{key}` | Full dotted key. |
|
|
252
|
-
| `{ext}` | Primary extension for the entry's format, without the leading dot (`md`/`json`/`yaml`/`txt`). |
|
|
253
|
-
|
|
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.
|
|
263
|
-
|
|
264
|
-
```yaml
|
|
265
|
-
- key: working.skills
|
|
266
|
-
path: working/skills
|
|
267
|
-
zone: working
|
|
268
|
-
schema: skill
|
|
269
|
-
nested: true
|
|
270
|
-
index_filename: SKILL.md
|
|
271
|
-
publish_each: "skills/{leaf}"
|
|
272
|
-
ignore: ["*.tmp", ".DS_Store"]
|
|
273
|
-
```
|
|
274
|
-
|
|
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.
|
|
276
|
-
|
|
277
|
-
**Subtree mirror (`publish_tree:`).** A nested manifest entry MAY declare `publish_tree:` to mirror its entire stored subtree (`zones/<path>/**`) to a single target directory, preserving relative layout (case and extension preserved). Unlike `publish_each:`, it is **path-driven, not key-driven**: no keys are enumerated, no template variables are interpreted, and the mirrored files are opaque payload (never addressable). The entry's `ignore:` globs (§4, ADR 0042) filter the walk; each mirrored file gets its own sentinel; and on every build the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). `publish_tree:` is mutually exclusive with `publish_to:` and `publish_each:`, and incompatible with `index_filename:`. When a `publish_tree:` target directory overlaps a `derived` entry's `publish_to:` (e.g. a derived `SKILL.md` written into the mirrored dir), the `publish_tree:` entry **must** `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap`. See ADR 0047.
|
|
245
|
+
**Subtree mirror (`publish_tree:`).** A nested manifest entry MAY declare `publish_tree:` to mirror its entire stored subtree (`zones/<path>/**`) to a single target directory, preserving relative layout (case and extension preserved). It is **path-driven, not key-driven**: no keys are enumerated, no template variables are interpreted, and the mirrored files are opaque payload (never addressable). The entry's `ignore:` globs (§4, ADR 0042) filter the walk; each mirrored file gets its own sentinel; and on every build the whole target directory is pruned of textus-managed files the current source no longer produces (unmanaged files are never touched). `publish_tree:` is mutually exclusive with `publish_to:`, and incompatible with `index_filename:`. When a `publish_tree:` target directory overlaps a `derived` entry's `publish_to:` (e.g. a derived `SKILL.md` written into the mirrored dir), the `publish_tree:` entry **must** `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap`. See ADR 0047.
|
|
278
246
|
|
|
279
247
|
```yaml
|
|
280
248
|
- key: working.skills
|
|
@@ -490,9 +458,7 @@ When the entry is recomputed, textus copies the in-store file byte-for-byte to e
|
|
|
490
458
|
|
|
491
459
|
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, marked as managed, or **byte-identical to the source being published**. An identical destination is *adopted*: its sentinel is written and management proceeds (the copy is a content no-op), so an artifact tree already on disk onboards without a manual delete. An unmanaged destination whose content **differs**, or any unmanaged symlink, is still refused (ADR 0050). Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
|
|
492
460
|
|
|
493
|
-
**
|
|
494
|
-
|
|
495
|
-
**Subtree mirror.** A nested entry MAY declare `publish_tree:` instead of `publish_to:` or `publish_each:` (see §4). On every build, textus walks the entry's full stored subtree (`zones/<path>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root>/sentinels/`. The mirror is path-driven: no keys are enumerated, no template variables are interpreted, and mirrored files are opaque payload (never addressable). On rebuild, the entire target directory is pruned of textus-managed files the current source no longer produces; unmanaged files are never touched. `publish_tree:` is mutually exclusive with `publish_to:` and `publish_each:`, and incompatible with `index_filename:`. When a `publish_tree:` target overlaps a `derived` entry's `publish_to:` (e.g. a derived `SKILL.md` written into the mirrored dir), the `publish_tree:` entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
|
|
461
|
+
**Subtree mirror.** A nested entry MAY declare `publish_tree:` instead of `publish_to:` (see §4). On every build, textus walks the entry's full stored subtree (`zones/<path>/**`), applies the entry's `ignore:` filter, and byte-copies each file to the target directory, preserving relative layout — one sentinel per file under `<store_root>/sentinels/`. The mirror is path-driven: no keys are enumerated, no template variables are interpreted, and mirrored files are opaque payload (never addressable). On rebuild, the entire target directory is pruned of textus-managed files the current source no longer produces; unmanaged files are never touched. The build envelope grows a `published_leaves` array — one row per mirrored file, with `key`, `source`, and `target` — alongside the existing `built` array, plus a `pruned` array listing any orphaned managed files removed on this build. `publish_tree:` is mutually exclusive with `publish_to:`, and incompatible with `index_filename:`. Targets that would resolve outside the repo root are refused. When a `publish_tree:` target overlaps a `derived` entry's `publish_to:` (e.g. a derived `SKILL.md` written into the mirrored dir), the `publish_tree:` entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
|
|
496
462
|
|
|
497
463
|
### 5.4 Intake (declared, fetched via registered intake handler)
|
|
498
464
|
|
|
@@ -83,7 +83,7 @@ A derived entry declares a `compute:` block with a `kind:` discriminator. Two ki
|
|
|
83
83
|
|
|
84
84
|
The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `compute.sources` — same list, recorded twice so a diff proves what was consumed.
|
|
85
85
|
|
|
86
|
-
Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Transforms (`compute.transform:`) and
|
|
86
|
+
Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Transforms (`compute.transform:`) and subtree publishing (`publish_tree:`) are also covered there.
|
|
87
87
|
|
|
88
88
|
## Intake and freshness
|
|
89
89
|
|
data/lib/textus/boot.rb
CHANGED
|
@@ -87,7 +87,7 @@ module Textus
|
|
|
87
87
|
{ "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
|
|
88
88
|
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
|
|
89
89
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
90
|
-
{ "name" => "build", "summary" => "materialize derived entries; publish_to and
|
|
90
|
+
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_tree fan out copies" },
|
|
91
91
|
{ "name" => "fetch" },
|
|
92
92
|
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
93
93
|
{ "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
|
|
@@ -225,7 +225,6 @@ module Textus
|
|
|
225
225
|
"derived" => derived,
|
|
226
226
|
"intake" => e.is_a?(Textus::Manifest::Entry::Intake),
|
|
227
227
|
"publish_to" => Array(e.publish_to),
|
|
228
|
-
"publish_each" => e.publish_each,
|
|
229
228
|
}
|
|
230
229
|
end
|
|
231
230
|
end
|
|
@@ -6,12 +6,11 @@ module Textus
|
|
|
6
6
|
# Entry::Publish::* — Nested is just the value (attributes + ignore
|
|
7
7
|
# predicate) those modes read.
|
|
8
8
|
class Nested < Base
|
|
9
|
-
attr_reader :index_filename, :
|
|
9
|
+
attr_reader :index_filename, :publish_tree, :ignore
|
|
10
10
|
|
|
11
|
-
def initialize(index_filename: nil,
|
|
11
|
+
def initialize(index_filename: nil, publish_tree: nil, ignore: nil, **rest)
|
|
12
12
|
super(**rest)
|
|
13
13
|
@index_filename = index_filename
|
|
14
|
-
@publish_each = publish_each
|
|
15
14
|
@publish_tree = publish_tree
|
|
16
15
|
@ignore = Array(ignore)
|
|
17
16
|
end
|
|
@@ -28,7 +27,6 @@ module Textus
|
|
|
28
27
|
def self.from_raw(common, raw)
|
|
29
28
|
new(
|
|
30
29
|
index_filename: raw["index_filename"],
|
|
31
|
-
publish_each: raw["publish_each"],
|
|
32
30
|
publish_tree: raw["publish_tree"],
|
|
33
31
|
ignore: raw["ignore"],
|
|
34
32
|
**common,
|
|
@@ -2,12 +2,12 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
module Publish
|
|
5
|
-
# ADR 0049: the one walk->publish->prune pipeline
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
11
|
class SubtreeMirror
|
|
12
12
|
def initialize(entry, pctx)
|
|
13
13
|
@entry = entry
|
|
@@ -16,8 +16,8 @@ module Textus
|
|
|
16
16
|
|
|
17
17
|
# base: store dir the entry owns — the root `ignored?` globs are
|
|
18
18
|
# relative to (ADR 0042).
|
|
19
|
-
# walk_root: dir the glob is rooted at (
|
|
20
|
-
#
|
|
19
|
+
# walk_root: dir the glob is rooted at (== base for Tree). dst paths
|
|
20
|
+
# mirror rel-to-walk_root.
|
|
21
21
|
# target_dir: repo-side destination root.
|
|
22
22
|
# key/envelope: emitted per file; envelope is nil for the keyless Tree.
|
|
23
23
|
# prune_honors_ignore: when true a managed file the entry `ignore`s
|
|
@@ -2,19 +2,13 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
module Publish
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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.
|
|
9
10
|
module Template
|
|
10
|
-
KNOWN_VARS = %w[leaf basename key ext].freeze
|
|
11
11
|
VAR_RE = /\{([a-z]+)\}/
|
|
12
|
-
REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
|
|
13
|
-
|
|
14
|
-
# Substitute `{var}` placeholders from a string-keyed hash.
|
|
15
|
-
def self.expand(template, vars)
|
|
16
|
-
template.gsub(VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
17
|
-
end
|
|
18
12
|
end
|
|
19
13
|
end
|
|
20
14
|
end
|
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
|
-
# ADR 0049: the publish design is a
|
|
4
|
+
# ADR 0049: the publish design is a key-split concept (ADR 0047 table)
|
|
5
5
|
# realized as one resolved sum type. Each directory entry resolves, once,
|
|
6
6
|
# to one Publish::* mode that owns its publish algorithm — no nil-cascade,
|
|
7
|
-
# no pairwise exclusivity guards, one shared subtree mirror.
|
|
7
|
+
# no pairwise exclusivity guards, one shared subtree mirror. ADR 0051
|
|
8
|
+
# removed `publish_each` (both leaf modes); the surface is now two modes:
|
|
8
9
|
#
|
|
9
10
|
# None — nothing to publish
|
|
10
11
|
# ToPaths — publish_to: 1 stored file -> N fixed repo paths
|
|
11
|
-
# EachFile — publish_each (file leaves): 1 leaf file -> 1 templated path
|
|
12
|
-
# EachDir — publish_each + index_filename: 1 leaf subtree -> 1 templated dir
|
|
13
12
|
# Tree — publish_tree: whole entry subtree -> 1 dir, no keys
|
|
14
13
|
module Publish
|
|
15
14
|
# Resolve an entry to its single publish mode. Raises one UsageError if
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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
18
|
def self.resolve(entry)
|
|
19
|
+
reject_removed_publish_each(entry)
|
|
20
|
+
|
|
19
21
|
set = []
|
|
20
22
|
set << "publish_to" unless Array(entry.publish_to).empty?
|
|
21
|
-
set << "publish_each" unless entry.publish_each.nil?
|
|
22
23
|
set << "publish_tree" unless entry.publish_tree.nil?
|
|
23
24
|
|
|
24
25
|
if set.length > 1
|
|
@@ -30,11 +31,20 @@ module Textus
|
|
|
30
31
|
mode_for(entry, set.first)
|
|
31
32
|
end
|
|
32
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
|
+
|
|
33
44
|
def self.mode_for(entry, key)
|
|
34
45
|
case key
|
|
35
46
|
when "publish_to" then ToPaths.new(entry)
|
|
36
47
|
when "publish_tree" then Tree.new(entry)
|
|
37
|
-
when "publish_each" then entry.index_filename ? EachDir.new(entry) : EachFile.new(entry)
|
|
38
48
|
else None.new(entry)
|
|
39
49
|
end
|
|
40
50
|
end
|
|
@@ -5,15 +5,15 @@ module Textus
|
|
|
5
5
|
# ADR 0049: one publish validator. Exclusivity among the publish keys is
|
|
6
6
|
# enforced structurally by Publish.resolve (reached via #publish_mode),
|
|
7
7
|
# and each mode's shape rules run *because that mode resolved* — replacing
|
|
8
|
-
# the
|
|
8
|
+
# the scattered pairwise "not-both" guards of the old PublishEach +
|
|
9
9
|
# PublishTree validators. Misuse on a non-nested entry is still caught
|
|
10
|
-
# here from raw, since the typed attrs stub nil on Base.
|
|
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.)
|
|
11
12
|
module Publish
|
|
12
13
|
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
13
14
|
unless entry.nested?
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
end
|
|
15
|
+
raise UsageError.new("entry '#{entry.key}': publish_tree requires nested: true") if entry.raw["publish_tree"]
|
|
16
|
+
|
|
17
17
|
return
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -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
|
|
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}]"
|
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/publish.rb
CHANGED
|
@@ -2,9 +2,9 @@ module Textus
|
|
|
2
2
|
module Write
|
|
3
3
|
# Single-pass publish use case: dispatches polymorphically to each
|
|
4
4
|
# entry's `publish_via` method. Derived entries materialize their body
|
|
5
|
-
# via Materializer; Nested entries
|
|
6
|
-
# Intake entries copy their stored body to publish_to targets.
|
|
7
|
-
# Publish layer owns wiring (context, accumulation) but not per-kind
|
|
5
|
+
# via Materializer; Nested entries mirror their subtree via publish_tree;
|
|
6
|
+
# Leaf and Intake entries copy their stored body to publish_to targets.
|
|
7
|
+
# The Publish layer owns wiring (context, accumulation) but not per-kind
|
|
8
8
|
# logic.
|
|
9
9
|
#
|
|
10
10
|
# Return shape: { "protocol", "built", "published_leaves" }
|
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.42.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -261,9 +261,6 @@ files:
|
|
|
261
261
|
- lib/textus/manifest/entry/nested.rb
|
|
262
262
|
- lib/textus/manifest/entry/parser.rb
|
|
263
263
|
- lib/textus/manifest/entry/publish.rb
|
|
264
|
-
- lib/textus/manifest/entry/publish/each.rb
|
|
265
|
-
- lib/textus/manifest/entry/publish/each_dir.rb
|
|
266
|
-
- lib/textus/manifest/entry/publish/each_file.rb
|
|
267
264
|
- lib/textus/manifest/entry/publish/mode.rb
|
|
268
265
|
- lib/textus/manifest/entry/publish/none.rb
|
|
269
266
|
- lib/textus/manifest/entry/publish/subtree_mirror.rb
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Manifest
|
|
3
|
-
class Entry
|
|
4
|
-
module Publish
|
|
5
|
-
# Shared base for the two key-driven publish_each modes (EachFile,
|
|
6
|
-
# EachDir). Owns the leaf enumeration, the `{...}` target templating, and
|
|
7
|
-
# the per-leaf repo-escape guard. Subclasses implement `#publish_leaf`,
|
|
8
|
-
# returning `{ written:, pruned: }`, and the discriminator half of
|
|
9
|
-
# `#validate!`.
|
|
10
|
-
class Each < Mode
|
|
11
|
-
def publish(pctx, prefix: nil)
|
|
12
|
-
leaves = []
|
|
13
|
-
pruned = []
|
|
14
|
-
pctx.manifest.resolver.enumerate(prefix: entry.key).each do |row|
|
|
15
|
-
next unless row[:manifest_entry].equal?(entry)
|
|
16
|
-
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
17
|
-
|
|
18
|
-
target_abs = guarded_target(pctx, row)
|
|
19
|
-
result = publish_leaf(row, target_abs, pctx)
|
|
20
|
-
pruned.concat(result[:pruned])
|
|
21
|
-
result[:written].each do |w|
|
|
22
|
-
leaves << { "key" => row[:key], "source" => w["source"], "target" => w["target"] }
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
{ kind: :leaves, value: leaves, pruned: pruned }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Expand this entry's publish_each template for a full leaf key.
|
|
30
|
-
def target_for(full_key)
|
|
31
|
-
entry_segs = entry.key.split(".")
|
|
32
|
-
key_segs = full_key.split(".")
|
|
33
|
-
raise UsageError.new("key '#{full_key}' is not under entry '#{entry.key}'") unless key_segs[0, entry_segs.length] == entry_segs
|
|
34
|
-
|
|
35
|
-
remaining = key_segs[entry_segs.length..] || []
|
|
36
|
-
Template.expand(
|
|
37
|
-
entry.publish_each,
|
|
38
|
-
"leaf" => remaining.join("/"),
|
|
39
|
-
"basename" => remaining.last || "",
|
|
40
|
-
"key" => full_key,
|
|
41
|
-
"ext" => ext,
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
def guarded_target(pctx, row)
|
|
48
|
-
target_rel = target_for(row[:key])
|
|
49
|
-
target_abs = repo_abs(pctx, target_rel)
|
|
50
|
-
return target_abs if inside_repo?(pctx, target_abs)
|
|
51
|
-
|
|
52
|
-
raise Textus::PublishError.new(
|
|
53
|
-
"entry '#{entry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def ext
|
|
58
|
-
Textus::Entry.for_format(entry.format).extensions.first.to_s.sub(/^\./, "")
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# publish_each shape rules common to file and directory leaves: a
|
|
62
|
-
# String value with only known template vars. Returns the used vars so
|
|
63
|
-
# subclasses can apply their discriminator rule.
|
|
64
|
-
def validate_template_basics
|
|
65
|
-
publish_each = entry.publish_each
|
|
66
|
-
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
|
|
67
|
-
|
|
68
|
-
used_vars = publish_each.scan(Template::VAR_RE).flatten
|
|
69
|
-
unknown = used_vars - Template::KNOWN_VARS
|
|
70
|
-
unless unknown.empty?
|
|
71
|
-
raise UsageError.new(
|
|
72
|
-
"entry '#{entry.key}': publish_each uses unknown template variable(s) " \
|
|
73
|
-
"#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{Template::KNOWN_VARS.map { |v| "{#{v}}" }.join(", ")}.",
|
|
74
|
-
)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
used_vars
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Manifest
|
|
3
|
-
class Entry
|
|
4
|
-
module Publish
|
|
5
|
-
# publish_each + index_filename (ADR 0046): each leaf is a whole subtree
|
|
6
|
-
# copied into one templated directory, layout preserved, then pruned.
|
|
7
|
-
# The template names the target DIRECTORY (not the index file).
|
|
8
|
-
class EachDir < Each
|
|
9
|
-
def publish_leaf(row, target_abs, pctx)
|
|
10
|
-
SubtreeMirror.new(entry, pctx).mirror(
|
|
11
|
-
base: store_base(pctx),
|
|
12
|
-
walk_root: File.dirname(row[:path]),
|
|
13
|
-
target_dir: target_abs,
|
|
14
|
-
key: row[:key],
|
|
15
|
-
envelope: pctx.reader.call(row[:key]),
|
|
16
|
-
prune_honors_ignore: false,
|
|
17
|
-
)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def validate!
|
|
21
|
-
used_vars = validate_template_basics
|
|
22
|
-
reject_file_only_vars(used_vars)
|
|
23
|
-
reject_index_filename_segment
|
|
24
|
-
reject_file_looking_segment
|
|
25
|
-
return if used_vars.intersect?(%w[leaf key])
|
|
26
|
-
|
|
27
|
-
raise UsageError.new(
|
|
28
|
-
"entry '#{entry.key}': directory-leaf publish_each must reference {leaf} or {key} " \
|
|
29
|
-
"(else every leaf would clobber the same directory).",
|
|
30
|
-
)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
def reject_file_only_vars(used_vars)
|
|
36
|
-
forbidden = used_vars & %w[basename ext]
|
|
37
|
-
return if forbidden.empty?
|
|
38
|
-
|
|
39
|
-
raise UsageError.new(
|
|
40
|
-
"entry '#{entry.key}': publish_each names a directory " \
|
|
41
|
-
"(index_filename: '#{entry.index_filename}'); {basename}/{ext} are file-only — " \
|
|
42
|
-
"use {leaf} or {key}.",
|
|
43
|
-
)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def reject_index_filename_segment
|
|
47
|
-
return unless last_segment == entry.index_filename
|
|
48
|
-
|
|
49
|
-
raise UsageError.new(
|
|
50
|
-
"entry '#{entry.key}': directory-leaf publish_each must name the target DIRECTORY, " \
|
|
51
|
-
"not the index file — drop the trailing '/#{entry.index_filename}' " \
|
|
52
|
-
"(the whole leaf subtree is copied into the named directory).",
|
|
53
|
-
)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def reject_file_looking_segment
|
|
57
|
-
ext = File.extname(last_segment)
|
|
58
|
-
return if ext.empty?
|
|
59
|
-
|
|
60
|
-
raise UsageError.new(
|
|
61
|
-
"entry '#{entry.key}': directory-leaf publish_each names a DIRECTORY target, but its " \
|
|
62
|
-
"final segment '#{last_segment}' looks like a file (extension '#{ext}') — " \
|
|
63
|
-
"drop the extension (the whole leaf subtree is copied into the named directory).",
|
|
64
|
-
)
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def last_segment
|
|
68
|
-
entry.publish_each.sub(%r{/\z}, "").split("/").last
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Manifest
|
|
3
|
-
class Entry
|
|
4
|
-
module Publish
|
|
5
|
-
# publish_each over file leaves (no index_filename): one stored leaf file
|
|
6
|
-
# copied to one templated repo path. No prune — each leaf is a single
|
|
7
|
-
# file, not a subtree.
|
|
8
|
-
class EachFile < Each
|
|
9
|
-
def publish_leaf(row, target_abs, pctx)
|
|
10
|
-
Textus::Ports::Publisher.publish(source: row[:path], target: target_abs, store_root: pctx.root)
|
|
11
|
-
pctx.emit(:file_published, key: row[:key], envelope: pctx.reader.call(row[:key]),
|
|
12
|
-
source: row[:path], target: target_abs)
|
|
13
|
-
{ written: [{ "source" => row[:path], "target" => target_abs }], pruned: [] }
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def validate!
|
|
17
|
-
used_vars = validate_template_basics
|
|
18
|
-
return if used_vars.intersect?(Template::REQUIRED_DISCRIMINATOR_VARS)
|
|
19
|
-
|
|
20
|
-
raise UsageError.new(
|
|
21
|
-
"entry '#{entry.key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
|
|
22
|
-
"(else every leaf would clobber the same target).",
|
|
23
|
-
)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|