textus 0.41.0 → 0.43.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4991eadd244e9ced0531683c7a40f27b88b825e23e8d0bd5ad5be7f91716e4c9
4
- data.tar.gz: d3e8d18c1e360455aef19bd7168b0ffb4516c54d0004253f34af78940752120d
3
+ metadata.gz: 8c2c92f27b720b90f0dfa268174cf9d9ea775b37f92bb38ed90cd9719d7b2626
4
+ data.tar.gz: f5018ecfd8d72666d48236d42395e44819766659088bc98b1c69ad4fccf2fce8
5
5
  SHA512:
6
- metadata.gz: f0f63a06265e280a8424476dff4941dfc0de016f68a9530ed53799429115416fea50f9f9479266baecd8d34c96e3bece97e1d97ab35446dd0dcb4cb2c7f61771
7
- data.tar.gz: 4cbfee016952dcc19312097018b18b63b8515d59786fddafc81d4a696737cbd3bd32f1d3dd45beae6d8660495c631defe70d324344c274ce02d505a3285ae8df
6
+ metadata.gz: 758b3a9da98edb000100e9b6d1665829d94333f53e38f3a68104d33a4aefd831947e235ecb3048e16b388e0256239dfe6f66708d1253b7bfcbb56360a74350bc
7
+ data.tar.gz: d72be31709261f1aff37f00a9b213e03d8121758f61e781bfdd1626eb16122d994d64491f4c37be4c851031e8bd122d9df8d8abd682228e2693162d3861c865d
data/CHANGELOG.md CHANGED
@@ -9,6 +9,32 @@ 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.43.0 — 2026-06-02 — Typed `publish:` block + remove `index_filename` ([ADR 0052](docs/architecture/decisions/0052-typed-publish-block.md), [0053](docs/architecture/decisions/0053-remove-index-filename.md))
13
+
14
+ No `textus/3` wire-format change. Two breaking (pre-1.0) changes on the publish/enumeration surface: the two top-level publish keys become one typed `publish:` block, and the unused `index_filename:` enumeration feature is removed. Both fail at load with a migration-pointing message.
15
+
16
+ ### Changed
17
+
18
+ - **BREAKING (pre-1.0): `publish_to:`/`publish_tree:` folded into one typed `publish:` block ([ADR 0052](docs/architecture/decisions/0052-typed-publish-block.md)).** Publishing is now configured by `publish: { to: [...] }` (file fan-out) **xor** `publish: { tree: "dir" }` (subtree mirror), mirroring the ADR 0049 internal sum type at the manifest layer and giving a future third mode a namespace instead of a third top-level key. Surface-only — the `Publish::*` modes, `:file_published` event, and build envelope are unchanged. A manifest using the flat `publish_to:`/`publish_tree:` keys **fails at load** with the replacement (`use publish: { to: [...] }` / `{ tree: "..." }`). This is grouping/extensibility, not a new invariant: a block with both `to:` and `tree:` is still caught by the one exclusivity guard in `Publish.resolve`.
19
+
20
+ ### Removed
21
+
22
+ - **BREAKING (pre-1.0): the `index_filename:` enumeration feature is removed ([ADR 0053](docs/architecture/decisions/0053-remove-index-filename.md)).** `index_filename:` let a nested entry enumerate *directories* as addressable keys via a fixed index file (e.g. `SKILL.md`). It had zero real usage, its only motivating consumer (`EachDir` per-leaf publish) was removed in 0.42.0, and it could not compose with `publish_tree` (mutually exclusive). A nested entry now always enumerates each file under its tree as a key (key segments derived from the path, extension stripped). A manifest declaring `index_filename:` **fails at load** with a migration message; to mirror a directory of files to a consumer path without enumerating them as keys, use `publish: { tree: "..." }`. Native skill authoring (ADR 0050) is unaffected — it rides `publish_tree`, which never enumerated an index. The shallowest-index-wins claiming logic (ADR 0046 D5) and the `index_filename ⊥ publish_tree` guard go with it.
23
+
24
+ No `textus/3` wire-format change — repo-local publish behaviour only.
25
+
26
+ ### Fixed
27
+
28
+ - **`publish_tree` files are now opaque in *every* path, not just the Publisher ([ADR 0047](docs/architecture/decisions/0047-publish-tree-keyless-subtree-mirror.md)).** A keyless subtree mirror carrying non-key-legal filenames (uppercase `SKILL.md`, `README`) tripped `doctor`'s `key.illegal` and red-gated commits, even though publish itself mirrored the files correctly — `doctor`'s `IllegalKeys` and `Resolver#enumerate_nested` still key-walked them, contradicting ADR 0047's "no keys" contract. The resolved publish mode now answers `Publish::Mode#keyless?` (true only for `Tree`); both paths consult it and skip enumerating a keyless mirror's files. A `publish_tree` subtree with uppercase filenames stays `doctor`-green and still mirrors; a non-publish nested entry still flags illegal segments as before.
29
+
30
+ ## 0.42.0 — 2026-06-02 — Remove `publish_each`: collapse publish to two modes ([ADR 0051](docs/architecture/decisions/0051-remove-publish-each.md))
31
+
32
+ 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`.
33
+
34
+ ### Removed
35
+
36
+ - **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.
37
+
12
38
  ## 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
39
 
14
40
  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:`/`publish_each:` byte-copy derived files to their consumer paths. ([SPEC §5.2–5.3](SPEC.md))
185
+ - **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files 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 target. `publish_each: "template/{basename}.md"` on a nested entry byte-copies every leaf to its templated target — substitutes `{leaf}`, `{basename}`, `{key}`, `{ext}`. Sentinels for every published file live under `.textus/sentinels/`. See SPEC §5.2, §5.3, §5.12.
206
+ Publishing is one typed `publish:` block (ADR 0052). `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
@@ -24,7 +24,7 @@
24
24
  - [5.2 Compute layer (derived entries)](#52-compute-layer-derived-entries)
25
25
  - [5.2.1 Projection compute](#521-projection-compute-kind-projection)
26
26
  - [5.2.2 External compute](#522-external-compute-kind-external)
27
- - [5.3 Publish layer](#53-publish-layer-publish_to)
27
+ - [5.3 Publish layer](#53-publish-layer-publish)
28
28
  - [5.4 Intake](#54-intake-declared-fetched-via-registered-intake-handler)
29
29
  - [5.5 Pending / accept workflow](#55-pending--accept-workflow)
30
30
  - [5.6 Audit log](#56-audit-log)
@@ -100,7 +100,7 @@ textus is organized as five composable layers. Each layer has a single responsib
100
100
  | L1 | **Store** | Plain-file backend: `.textus/zones/<zone>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
101
101
  | L2 | **Sources** | Declared external inputs (the `feeds` zone in the default scaffold; any `quarantine` zone, writable by a role with `fetch`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external automation fetches and pipes results through `textus put`. |
102
102
  | L3 | **Compute** | Pure transforms from store entries to derived entries. Projections (select/pluck/sort/limit/format) plus a vendored Mustache template subset. No shell execution. |
103
- | L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `publish_to:`. The in-store artifact is the consumer-shaped output; the published file is an identical copy. A sentinel under `.textus/sentinels/<target-rel-path>.textus-managed.json` records the source, sha256, and `mode: "copy"`. |
103
+ | L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `publish: { to: [...] }`. The in-store artifact is the consumer-shaped output; the published file is an identical copy. A sentinel under `.textus/sentinels/<target-rel-path>.textus-managed.json` records the source, sha256, and `mode: "copy"`. |
104
104
  | L5 | **Consumers** | Anything that reads the published files or calls the CLI — editors, LLM tools, MCP servers, CI jobs, dashboards. textus is agnostic about who consumes; the envelope is the contract. |
105
105
 
106
106
  ## 2. Goals and non-goals
@@ -228,38 +228,11 @@ Zone names are conventional — write authority comes from each zone's declared
228
228
  | `yaml` | `.yaml` or `.yml` required | optional (escape hatch) | optional (top-level keys) |
229
229
  | `text` | `.txt` or no extension | required for derived | MUST be null |
230
230
 
231
- For `nested: true`, the recursive glob matches the format's extension (markdown→`**/*.md`, json→`**/*.json`, yaml→`**/*.{yaml,yml}`, text→`**/*.txt`). All files under one nested entry share one format and one schema.
231
+ For `nested: true`, the recursive glob matches the format's extension (markdown→`**/*.md`, json→`**/*.json`, yaml→`**/*.{yaml,yml}`, text→`**/*.txt`). All files under one nested entry share one format and one schema. Each matching file is enumerated as its own key, with the key segments derived from the path relative to the entry (extension stripped). A nested entry that instead mirrors a whole directory of files to a consumer path — without enumerating any of them as keys — uses `publish: { tree: }` (below); its files are opaque payload. (The former `index_filename:` directory-keyed enumeration was removed in 0.43.0 — ADR 0053.)
232
232
 
233
- **Per-entry `index_filename:`.** A nested entry MAY declare `index_filename:` to surface a single fixed basename (e.g. `SKILL.md`) per directory as the row, with the row's key segments derived from the directory path. Sibling files are not enumerated. The basename's extension MUST match the entry's `format:`. This lets entries project spec-mandated filenames whose casing would otherwise be rejected by the key-segment grammar. Example:
233
+ **The `publish:` block (ADR 0052).** Publishing is configured by one typed `publish:` block with exactly one of two sub-keys `publish: { to: [...] }` (file fan-out, §5.3) **xor** `publish: { tree: "dir" }` (subtree mirror, below). Setting both is an error. The legacy top-level `publish_to:` / `publish_tree:` keys are rejected at load with a migration message.
234
234
 
235
- ```yaml
236
- - key: skills
237
- path: skills
238
- zone: skills
239
- nested: true
240
- index_filename: SKILL.md
241
- ```
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).
244
-
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
-
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.
235
+ **Subtree mirror (`publish: { tree: }`).** A nested manifest entry MAY declare `publish: { tree: "dir" }` 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). 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 mirroring entry **must** `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap`. See ADR 0047.
263
236
 
264
237
  ```yaml
265
238
  - key: working.skills
@@ -267,22 +240,8 @@ Validation at manifest load: any unknown variable raises `UsageError`; a compute
267
240
  zone: working
268
241
  schema: skill
269
242
  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.
278
-
279
- ```yaml
280
- - key: working.skills
281
- path: working/skills
282
- zone: working
283
- schema: skill
284
- nested: true
285
- publish_tree: "skills"
243
+ publish:
244
+ tree: "skills"
286
245
  ignore: ["*.tmp", ".DS_Store"]
287
246
  ```
288
247
 
@@ -476,23 +435,24 @@ generated:
476
435
 
477
436
  `kind: external` and `kind: projection` are alternatives — exactly one per entry. Templates are not required for `kind: external`: the external automation produces the bytes directly.
478
437
 
479
- ### 5.3 Publish layer (`publish_to:`)
438
+ ### 5.3 Publish layer (`publish:`)
480
439
 
481
- A derived entry MAY declare `publish_to:` in its frontmatter, listing one or more destination paths relative to the project root:
440
+ Publishing is configured by one typed `publish:` block with exactly one sub-key (ADR 0052): `to:` (file fan-out) **xor** `tree:` (subtree mirror). Setting both is an error; the legacy top-level `publish_to:` / `publish_tree:` keys are rejected at load with a migration message.
441
+
442
+ A derived entry MAY declare `publish: { to: [...] }`, listing one or more destination paths relative to the project root:
482
443
 
483
444
  ```yaml
484
- publish_to:
485
- - CLAUDE.md
486
- - .ai/instructions.md
445
+ publish:
446
+ to:
447
+ - CLAUDE.md
448
+ - .ai/instructions.md
487
449
  ```
488
450
 
489
451
  When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/<output-zone>/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
490
452
 
491
453
  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
454
 
493
- **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.
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).
455
+ **Subtree mirror.** A nested entry MAY declare `publish: { tree: "dir" }` instead of `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. 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 mirroring entry must `ignore:` that filename or prune will delete it — `doctor` flags this as `publish.tree_index_overlap` (ADR 0047).
496
456
 
497
457
  ### 5.4 Intake (declared, fetched via registered intake handler)
498
458
 
@@ -1052,7 +1012,7 @@ Given a manifest entry `derived.catalogs.skills` whose `compute: { kind: project
1052
1012
  Given a derived entry with a `template` clause referencing a `.mustache` file and inputs drawn from other keys, `textus build` produces a body whose contents match the expected rendered output byte-for-byte (after trailing-newline normalization).
1053
1013
 
1054
1014
  **Fixture G — Copy publish:**
1055
- Given a manifest entry with `publish_to: <path>`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
1015
+ Given a manifest entry with `publish: { to: [<path>] }`, a successful `textus build` for that entry leaves a plain file at `<path>` whose contents are byte-identical to the in-store artifact at `.textus/zones/<...>`, accompanied by a sentinel at `.textus/sentinels/<path>.textus-managed.json` recording `source`, `target`, `sha256`, and `mode: "copy"`. Re-running `build` is idempotent.
1056
1016
 
1057
1017
  **Fixture H — Audit log format:**
1058
1018
  Every successful write verb (`put`, `delete`, `build`, `accept`, `schema migrate`) appends exactly one line per affected key to the audit log, in the canonical format defined in §audit (timestamp, actor role, verb, key, etag-before, etag-after). No write produces zero or multiple lines per key.
@@ -65,7 +65,8 @@ A derived entry declares a `compute:` block with a `kind:` discriminator. Two ki
65
65
  pluck: [name, relationship, org]
66
66
  sort_by: name
67
67
  template: people.mustache # under .textus/templates/
68
- publish_to: [docs/people.md] # optional repo-relative byte-copy targets
68
+ publish:
69
+ to: [docs/people.md] # optional repo-relative byte-copy targets
69
70
  ```
70
71
 
71
72
  **`compute: { kind: external }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `textus freshness` can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `build` (default: `automation`).
@@ -83,7 +84,7 @@ A derived entry declares a `compute:` block with a `kind:` discriminator. Two ki
83
84
 
84
85
  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
86
 
86
- Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Transforms (`compute.transform:`) and per-leaf publishing (`publish_each:`) are also covered there.
87
+ 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
88
 
88
89
  ## Intake and freshness
89
90
 
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 publish_each fan out copies" },
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,12 @@ module Textus
6
6
  out = []
7
7
  manifest.data.entries.each do |entry|
8
8
  next unless entry.nested?
9
+ next if entry.publish_mode.keyless? # publish_tree files are opaque payload, never keys (ADR 0047)
9
10
 
10
11
  base = File.join(root, "zones", entry.path)
11
12
  next unless File.directory?(base)
12
13
 
13
- index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
14
- index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(entry, base, out)
14
+ check_all_paths(entry, base, out)
15
15
  end
16
16
  out
17
17
  end
@@ -31,24 +31,6 @@ module Textus
31
31
  end
32
32
  end
33
33
 
34
- # When the entry uses `index_filename:`, only the parent-directory
35
- # segments leading to each index file participate in keys. Sibling
36
- # files and unrelated subtrees are not enumerated and must not be
37
- # flagged. Each illegal segment is reported once per path. Paths under
38
- # an ignored subtree (ADR 0042) are excluded before any segment check.
39
- def check_index_paths(entry, index_fn, base, out)
40
- Dir.glob(File.join(base, "**", index_fn)).each do |fp|
41
- rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
42
- next if entry.ignored?(rel)
43
-
44
- File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
45
- next if seg.match?(Key::Grammar::SEGMENT)
46
-
47
- out << issue(fp, seg)
48
- end
49
- end
50
- end
51
-
52
34
  def issue(abs_path, stem)
53
35
  {
54
36
  "code" => "key.illegal",
@@ -43,9 +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
47
46
  def publish_tree = nil
48
- def index_filename = nil
49
47
  def ignore = []
50
48
 
51
49
  # Per-entry ignore (ADR 0042). Base entries enumerate no tree, so
@@ -6,12 +6,10 @@ 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, :publish_each, :publish_tree, :ignore
9
+ attr_reader :publish_tree, :ignore
10
10
 
11
- def initialize(index_filename: nil, publish_each: nil, publish_tree: nil, ignore: nil, **rest)
11
+ def initialize(publish_tree: nil, ignore: nil, **rest)
12
12
  super(**rest)
13
- @index_filename = index_filename
14
- @publish_each = publish_each
15
13
  @publish_tree = publish_tree
16
14
  @ignore = Array(ignore)
17
15
  end
@@ -27,9 +25,7 @@ module Textus
27
25
 
28
26
  def self.from_raw(common, raw)
29
27
  new(
30
- index_filename: raw["index_filename"],
31
- publish_each: raw["publish_each"],
32
- publish_tree: raw["publish_tree"],
28
+ publish_tree: raw.dig("publish", "tree"), # ADR 0052: typed publish block
33
29
  ignore: raw["ignore"],
34
30
  **common,
35
31
  )
@@ -18,7 +18,10 @@ module Textus
18
18
  key: key, path: path, zone: zone,
19
19
  schema: raw["schema"], owner: raw["owner"],
20
20
  format: format,
21
- publish_to: raw["publish_to"]
21
+ # ADR 0052: publish config is one typed block; the internal
22
+ # publish_to/publish_tree readers (the ADR 0049 modes) are sourced
23
+ # from it (publish_to <- publish.to, publish_tree <- publish.tree).
24
+ publish_to: raw.dig("publish", "to")
22
25
  }
23
26
 
24
27
  klass = Entry::REGISTRY[kind] or
@@ -17,6 +17,12 @@ module Textus
17
17
  # No shape rules by default — ToPaths/None publish without templating.
18
18
  def validate!; end
19
19
 
20
+ # Whether this entry's subtree files are opaque payload that must
21
+ # never be enumerated as keys. Only Tree (publish_tree, ADR 0047)
22
+ # overrides to true; doctor's IllegalKeys and the resolver consult
23
+ # this so they stop key-walking a keyless mirror's files.
24
+ def keyless? = false
25
+
20
26
  private
21
27
 
22
28
  # Expand `rel` under repo_root and confirm it stays inside it.
@@ -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 shared by EachDir
6
- # (per-leaf subtree, ADR 0046) and Tree (whole-entry mirror, ADR 0047).
7
- # The two used to be near-duplicate methods (publish_subtree +
8
- # publish_tree_via, prune_orphans + prune_tree); their only real
9
- # differencewhether the prune honors the entry's `ignore` is now the
10
- # explicit `prune_honors_ignore:` parameter.
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 (a single leaf dir for EachDir,
20
- # == base for Tree). dst paths mirror rel-to-walk_root.
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
- # The publish_each template vocabulary. A publish_each value is a path
6
- # with `{leaf}`, `{basename}`, `{key}`, `{ext}` placeholders; a
7
- # publish_tree value must be a plain path (any var is an error), so the
8
- # modes reuse VAR_RE to detect stray vars there too.
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
@@ -2,8 +2,8 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
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:`.
5
+ # publish.to: copy the entry's one stored file to each fixed repo path.
6
+ # The behaviour of any entry that declares `publish: { to: [...] }`.
7
7
  class ToPaths < Mode
8
8
  def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
9
9
  targets = Array(entry.publish_to)
@@ -8,6 +8,9 @@ module Textus
8
8
  # (e.g. a SKILL.md written by a separate entry into the same dir)
9
9
  # survives the whole-target prune (ADR 0047 D4).
10
10
  class Tree < Mode
11
+ # Mirrored files are opaque payload, never addressable keys (ADR 0047).
12
+ def keyless? = true
13
+
11
14
  def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
12
15
  target_rel = entry.publish_tree
13
16
  target_dir = repo_abs(pctx, target_rel)
@@ -30,20 +33,13 @@ module Textus
30
33
 
31
34
  def validate!
32
35
  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
36
+ raise UsageError.new("entry '#{entry.key}': publish.tree must be a string") unless publish_tree.is_a?(String)
41
37
 
42
38
  used_vars = publish_tree.scan(Template::VAR_RE).flatten
43
39
  return if used_vars.empty?
44
40
 
45
41
  raise UsageError.new(
46
- "entry '#{entry.key}': publish_tree names a single directory and takes no template variable(s) " \
42
+ "entry '#{entry.key}': publish.tree names a single directory and takes no template variable(s) " \
47
43
  "#{used_vars.map { |v| "{#{v}}" }.join(", ")} — it mirrors the whole subtree to one target dir.",
48
44
  )
49
45
  end
@@ -1,40 +1,52 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Entry
4
- # ADR 0049: the publish design is a three-key concept (ADR 0047 table)
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); ADR 0052 folded the two surviving
9
+ # keys into one `publish:` block (`to:` xor `tree:`). The surface is two modes:
8
10
  #
9
- # None — nothing to publish
10
- # 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
- # Tree — publish_tree: whole entry subtree -> 1 dir, no keys
11
+ # None — nothing to publish (no publish: block)
12
+ # ToPaths — publish: { to: [...] } — 1 stored file -> N fixed repo paths
13
+ # Tree publish: { tree: "dir" } whole entry subtree -> 1 dir, no keys
14
14
  module Publish
15
- # Resolve an entry to its single publish mode. Raises one UsageError if
16
- # more than one of {publish_to, publish_each, publish_tree} is set —
17
- # exclusivity is structural here, not four scattered pairwise guards.
15
+ # Resolve an entry to its single publish mode. The publish config is the
16
+ # ADR 0052 `publish:` block, sourced into entry.publish_to/publish_tree.
17
+ # Raises one UsageError if both `publish.to` and `publish.tree` are set —
18
+ # the block groups the two but does not make exclusivity structural, so
19
+ # this stays the one enforcement point (ADR 0052 D2).
18
20
  def self.resolve(entry)
21
+ reject_removed_publish_each(entry)
22
+
19
23
  set = []
20
- set << "publish_to" unless Array(entry.publish_to).empty?
21
- set << "publish_each" unless entry.publish_each.nil?
22
- set << "publish_tree" unless entry.publish_tree.nil?
24
+ set << "publish.to" unless Array(entry.publish_to).empty?
25
+ set << "publish.tree" unless entry.publish_tree.nil?
23
26
 
24
27
  if set.length > 1
25
28
  raise Textus::UsageError.new(
26
- "entry '#{entry.key}': #{set.join(", ")} are mutually exclusive — an entry publishes exactly one way",
29
+ "entry '#{entry.key}': #{set.join(" and ")} are mutually exclusive — an entry publishes exactly one way",
27
30
  )
28
31
  end
29
32
 
30
33
  mode_for(entry, set.first)
31
34
  end
32
35
 
36
+ def self.reject_removed_publish_each(entry)
37
+ return unless entry.raw["publish_each"]
38
+
39
+ raise Textus::UsageError.new(
40
+ "entry '#{entry.key}': publish_each was removed in 0.42.0 (ADR 0051) — " \
41
+ "mirror the subtree with `publish: { tree: \"...\" }`.",
42
+ )
43
+ end
44
+ private_class_method :reject_removed_publish_each
45
+
33
46
  def self.mode_for(entry, key)
34
47
  case key
35
- when "publish_to" then ToPaths.new(entry)
36
- when "publish_tree" then Tree.new(entry)
37
- when "publish_each" then entry.index_filename ? EachDir.new(entry) : EachFile.new(entry)
48
+ when "publish.to" then ToPaths.new(entry)
49
+ when "publish.tree" then Tree.new(entry)
38
50
  else None.new(entry)
39
51
  end
40
52
  end
@@ -5,15 +5,17 @@ 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 four scattered pairwise "not-both" guards of the old PublishEach +
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; the publish_to/publish_tree keys were folded
12
+ # into the `publish:` block in 0.43.0 — ADR 0052; Schema rejects the retired
13
+ # flat keys at load.)
11
14
  module Publish
12
15
  def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
13
16
  unless entry.nested?
14
- %w[publish_each publish_tree].each do |key|
15
- raise UsageError.new("entry '#{entry.key}': #{key} requires nested: true") if entry.raw[key]
16
- end
17
+ raise UsageError.new("entry '#{entry.key}': publish.tree requires nested: true") if entry.raw.dig("publish", "tree")
18
+
17
19
  return
18
20
  end
19
21
 
@@ -6,7 +6,6 @@ module Textus
6
6
  Events,
7
7
  Publish,
8
8
  InjectBoot,
9
- IndexFilename,
10
9
  Ignore,
11
10
  FormatMatrix,
12
11
  ].freeze
@@ -51,13 +51,8 @@ module Textus
51
51
  else
52
52
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
53
53
 
54
- index_fn = entry.index_filename
55
- path = if index_fn
56
- File.join(@data.root, "zones", entry.path, *remaining, index_fn)
57
- else
58
- primary_ext = Textus::Entry.for_format(entry.format).extensions.first
59
- File.join(@data.root, "zones", entry.path, *remaining) + primary_ext
60
- end
54
+ primary_ext = Textus::Entry.for_format(entry.format).extensions.first
55
+ path = File.join(@data.root, "zones", entry.path, *remaining) + primary_ext
61
56
  Resolution.new(entry: entry, path: path, remaining: remaining)
62
57
  end
63
58
  end
@@ -68,33 +63,22 @@ module Textus
68
63
  end
69
64
 
70
65
  def enumerate_nested(entry)
66
+ # publish_tree mirrors opaque payload by path — its files are never
67
+ # enumerated as keys (ADR 0047). Ask the resolved mode, not the path.
68
+ return [] if entry.publish_mode.keyless?
69
+
71
70
  base = File.join(@data.root, "zones", entry.path)
72
71
  return [] unless File.directory?(base)
73
72
 
74
- entry_index_filename = entry.index_filename
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
73
+ Dir.glob(File.join(base, nested_glob(entry.format)))
74
+ .filter_map { |path| nested_row_for(entry, base, path) }
90
75
  end
91
76
 
92
77
  def nested_row_for(entry, base, path)
93
78
  rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
94
79
  return nil if entry.ignored?(rel)
95
80
 
96
- entry_if = entry.index_filename
97
- stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
81
+ stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
98
82
  segs = stripped.split("/").reject { |s| s.empty? || s == "." }
99
83
  return nil if segs.empty?
100
84
 
@@ -24,9 +24,12 @@ 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 publish_tree
28
- intake events inject_boot index_filename ignore tracked
27
+ compute template publish
28
+ intake events inject_boot ignore tracked
29
29
  ].freeze
30
+ # ADR 0052: the typed publish block — `publish: { to: [...] }` (file
31
+ # fan-out) xor `publish: { tree: "dir" }` (subtree mirror).
32
+ PUBLISH_KEYS = %w[to tree].freeze
30
33
  COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
31
34
  INTAKE_KEYS = %w[handler config].freeze
32
35
  RULE_KEYS = %w[match fetch intake_handler_allowlist guard retention].freeze
@@ -73,12 +76,62 @@ module Textus
73
76
  def self.validate_entries!(entries)
74
77
  Array(entries).each_with_index do |e, i|
75
78
  path = "$.entries[#{i}]"
79
+ reject_retired_publish_keys!(e, path)
76
80
  walk(e, ENTRY_KEYS, path)
81
+ validate_publish_block!(e, path)
77
82
  walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
78
83
  walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
79
84
  end
80
85
  end
81
86
 
87
+ # Retired keys are no longer allowed, so `walk` would reject them as merely
88
+ # "unknown"; intercept first with the migration path so a pre-0.43 manifest
89
+ # gets a useful error. `publish_each` was removed (ADR 0051); `publish_to`/
90
+ # `publish_tree` were folded into the `publish:` block (ADR 0052);
91
+ # `index_filename` was removed (ADR 0053).
92
+ def self.reject_retired_publish_keys!(entry, path)
93
+ return unless entry.is_a?(Hash)
94
+
95
+ if entry.key?("publish_each")
96
+ raise BadManifest.new(
97
+ "publish_each was removed in 0.42.0 (ADR 0051) at '#{path}' — " \
98
+ "mirror the subtree with `publish: { tree: \"...\" }`.",
99
+ )
100
+ end
101
+
102
+ if entry.key?("publish_to")
103
+ raise BadManifest.new(
104
+ "publish_to was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
105
+ "use `publish: { to: [...] }`.",
106
+ )
107
+ end
108
+
109
+ if entry.key?("publish_tree")
110
+ raise BadManifest.new(
111
+ "publish_tree was replaced by the publish: block in 0.43.0 (ADR 0052) at '#{path}' — " \
112
+ "use `publish: { tree: \"...\" }`.",
113
+ )
114
+ end
115
+
116
+ return unless entry.key?("index_filename")
117
+
118
+ raise BadManifest.new(
119
+ "index_filename was removed in 0.43.0 (ADR 0053) at '#{path}' — a nested entry now enumerates " \
120
+ "each file as a key; to mirror a directory of files to a consumer path use `publish: { tree: \"...\" }`.",
121
+ )
122
+ end
123
+
124
+ # Shape of the ADR 0052 publish block: a Hash whose only keys are to/tree.
125
+ # Exclusivity (both set) and per-mode rules stay in Publish.resolve (ADR 0049).
126
+ def self.validate_publish_block!(entry, path)
127
+ return unless entry.is_a?(Hash) && entry.key?("publish")
128
+
129
+ block = entry["publish"]
130
+ raise BadManifest.new("publish: must be a mapping with `to:` or `tree:` at '#{path}.publish'") unless block.is_a?(Hash)
131
+
132
+ walk(block, PUBLISH_KEYS, "#{path}.publish")
133
+ end
134
+
82
135
  def self.validate_rules!(rules)
83
136
  Array(rules).each_with_index do |r, i|
84
137
  path = "$.rules[#{i}]"
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.41.0"
2
+ VERSION = "0.43.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -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 fan out via publish_each; Leaf and
6
- # Intake entries copy their stored body to publish_to targets. The
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.41.0
4
+ version: 0.43.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
@@ -274,7 +271,6 @@ files:
274
271
  - lib/textus/manifest/entry/validators/events.rb
275
272
  - lib/textus/manifest/entry/validators/format_matrix.rb
276
273
  - lib/textus/manifest/entry/validators/ignore.rb
277
- - lib/textus/manifest/entry/validators/index_filename.rb
278
274
  - lib/textus/manifest/entry/validators/inject_boot.rb
279
275
  - lib/textus/manifest/entry/validators/publish.rb
280
276
  - lib/textus/manifest/policy.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
@@ -1,47 +0,0 @@
1
- module Textus
2
- class Manifest
3
- class Entry
4
- module Validators
5
- module IndexFilename
6
- def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
7
- # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
8
- index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
9
- return if index_filename.nil?
10
-
11
- check_shape!(entry, index_filename)
12
- check_extension!(entry, index_filename)
13
- end
14
-
15
- def self.check_shape!(entry, index_filename)
16
- raise UsageError.new("entry '#{entry.key}': index_filename requires nested: true") unless entry.nested?
17
-
18
- unless index_filename.is_a?(String) && !index_filename.empty?
19
- raise UsageError.new("entry '#{entry.key}': index_filename must be a non-empty string")
20
- end
21
-
22
- return unless index_filename.include?("/") || File.basename(index_filename) != index_filename
23
-
24
- raise UsageError.new("entry '#{entry.key}': index_filename must be a bare basename (no slashes)")
25
- end
26
-
27
- def self.check_extension!(entry, index_filename)
28
- ext = File.extname(index_filename)
29
- inferred = Textus::Entry.infer_from_extension(ext)
30
-
31
- if inferred.nil?
32
- raise UsageError.new(
33
- "entry '#{entry.key}': index_filename #{index_filename.inspect} has unknown extension #{ext.inspect}",
34
- )
35
- end
36
- return if inferred == entry.format
37
-
38
- raise UsageError.new(
39
- "entry '#{entry.key}': index_filename extension #{ext.inspect} implies format #{inferred.inspect}, " \
40
- "but entry format is #{entry.format.inspect}",
41
- )
42
- end
43
- end
44
- end
45
- end
46
- end
47
- end