textus 0.10.3 → 0.10.5

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: c38f76b221f200d4af26262a94de566a9f43949282c0a060472ed85974657ac5
4
- data.tar.gz: 86231d26523777d9b6751e33c09138efb921bc36ed446c32ae4537371515e7dd
3
+ metadata.gz: 79987029ce43500b025495ef26cefc285f7a748cdcd43388aff2fefafdf4c0ca
4
+ data.tar.gz: 44a9d40720a84e4e942a1c4fde9e0ed9e7773126f443b247f6d8cb27d630c204
5
5
  SHA512:
6
- metadata.gz: bc9c2677e749237c9bc78a3b4005e4cddd1228f95044e60afc04a2ac3323dd266aa02bffff9173d7b70d5c0cb33d04a08375ecefbf0ad0b852b43a294443fcf8
7
- data.tar.gz: df18177f6df9bbcc9bb8467b2f4736e0d71f2a78a0f7dfed74865ad0f7e8b84028c0c75690357367bde106a5c5d779b03fee4f6d5e73b8e245c7e14c104d9c30
6
+ metadata.gz: a539030b1d406226bbe3d3fad03b3daee59094d3d8db351d97c9c5adc445ca8c188a7d13a16cdfaaae0c933091e6a8858c14a91362f461f25a1dce52aa04a60f
7
+ data.tar.gz: 83667dab7c91d4c2e9def3c2a2d605cf45d84f3fdf89bf0e6d8d25ff2b8fe668d6b71ceae0488e7ffe24970bf80e0f5eeeb13c308b2d16c9d11fc41633160dd6
data/CHANGELOG.md CHANGED
@@ -8,6 +8,47 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
8
8
  (currently `textus/2`, embedded in every envelope as `protocol`). The protocol
9
9
  is additive within a major; a new major would change the wire string.
10
10
 
11
+ ## [Unreleased]
12
+
13
+ ## 0.10.5 — tech-debt cleanup + `index_filename:` + docs polish (2026-05-25)
14
+
15
+ Patch release. One user-facing feature (`index_filename:` on nested manifest entries) plus internal refactors that remove 7 of 19 `rubocop:disable` suppressions. No protocol bump; existing manifests parse unchanged.
16
+
17
+ ### Added
18
+
19
+ - **Per-entry `index_filename:` on nested manifest entries.** A nested entry MAY declare `index_filename: SKILL.md` (or any other bare basename) to surface that single file per directory as the row; the row's key segments come from the directory path, and siblings are not enumerated. Lets entries project spec-mandated filenames (e.g. agentskills.io's `SKILL.md`) whose uppercase casing would otherwise be rejected by the `[a-z0-9][a-z0-9-]*` key-segment grammar. `resolve(key)` returns the index-filename path for sub-directories. Validation: requires `nested: true`, basename only (no slashes), extension must match the entry's `format:`. New spec `spec/manifest_index_filename_spec.rb`. Documented in SPEC §4.
20
+
21
+ ### Internal
22
+
23
+ - **`Store::Mover#call` refactor.** Replaces an 81-line method (suppressed `Metrics/AbcSize, Metrics/MethodLength`) with an 8-line orchestrator sequenced over four named private phases — `prepare_plan`, `ensure_uid!`, `perform_move`, `record_move` — coordinated through a `MovePlan` value object. The pre-read envelope is threaded separately so `MovePlan` describes only the planned operation.
24
+ - **`Store::Staleness#call` split.** Replaces a 70-line dual-loop method (the most aggressive suppression in the gem — `AbcSize, CyclomaticComplexity, MethodLength, PerceivedComplexity, BlockLength`) with a composer + two single-purpose checks (`GeneratorCheck`, `IntakeCheck`) and a private filter method on the composer. Each new unit fits default rubocop thresholds.
25
+ - **`Store::Writer` payload + ctx grouping.** Collapses `write_envelope_to_disk`'s 8 keyword args to 5 by introducing `Store::Writer::Payload = Data.define(:meta, :body, :content)` and reusing `Application::Context` for `role` + `correlation_id`. Applies the same `ctx:` pattern to the sibling `delete_envelope_from_disk`. The class-wide `Metrics/ParameterLists` disable is narrowed to a method-level disable on the `put` back-compat shim (which mirrors the user-facing `Store#put` 7-kwarg signature and cannot be changed).
26
+ - **Net suppression change:** 19 `rubocop:disable` lines → 17; 7 metric-cop suppressions removed; one `ParameterLists` disable narrowed in scope. Full suite unchanged.
27
+
28
+ ### Documentation
29
+
30
+ - **SPEC.md §5.2.1 added.** Documents the `generator:` field — the externally-generated-derived-entry shape (build runner produces the file; textus tracks `sources:` for staleness via `_meta.generated.at`). The field was always parsed and tested but had no spec coverage. Clarifies that textus never executes `command:` — consistent with §2 "Not an executor."
31
+ - **README.md trimmed.** Removed the duplicated "CLI verbs" and "Zones and roles" tables; readers are pointed at SPEC §5 / §9 and `docs/zones.md` for the canonical surfaces. README narrative kept.
32
+ - **docs/conventions.md** now covers both derived-entry shapes (`projection:` for declarative compute inside textus; `generator:` for external build tools) and the current intake / freshness model (top-level `policies:` + `textus refresh-stale`). Replaces a stale section that described a pre-0.4 build-runner pattern.
33
+ - **CONTRIBUTING.md** sources-of-truth pointer updated. Per-release implementation plans are kept locally by maintainers and no longer signposted in public docs.
34
+
35
+ ## 0.10.4 — GitHub folder intake recipe + skill-bundle deferral ADR (2026-05-24)
36
+
37
+ Patch release. Ships a working "pull a GitHub folder as a skill bundle, fan it out to derived entries" pattern as opt-in example hooks under `examples/claude-plugin/recipes/`, with hermetic specs and user-facing docs. Captures the design decision to defer first-class skill-bundle support to a future release. No `lib/` changes; CLI, wire protocol, event surface, manifest schema, and doctor checks are all unchanged.
38
+
39
+ ### Added
40
+
41
+ - **`examples/claude-plugin/recipes/github_folder.rb`** — `Textus.intake(:github_folder)` handler that fetches a folder from a public GitHub repo via the REST tree + blob endpoints and returns a single entry whose `content.files` is a `{ relative_path => bytes }` hash. Uses Ruby stdlib (`net/http`, `json`, `base64`); no new gem dependencies. Fetcher is injectable for testability.
42
+ - **`examples/claude-plugin/recipes/skill_fanout.rb`** — `Textus.refreshed(:skill_fanout, keys: "intake.skills.*")` listener that fans a bundle out into `vendor.skills.<slug>.*` derived entries with reconciliation: orphaned children whose source path disappeared upstream are deleted. Inner writes use `suppress_events: true` to prevent recursion.
43
+ - **`examples/claude-plugin/recipes/README.md`** — explains that files in `recipes/` are opt-in and do not auto-load (they live outside `.textus/hooks/`).
44
+ - **`docs/recipe-github-skill-bundle.md`** — end-to-end recipe: manifest snippet, copy commands, caveats (30s timeout, recursion guard, public-repos-only, hook-not-bundled-with-content).
45
+ - **`docs/architecture/decisions/0001-skill-bundle-deferral.md`** — Architecture Decision Record documenting the friction in the current primitives, the three-option design space (status quo, intake-returns-N-entries, hooks-as-content), the choice to stay at status quo, and the explicit criteria for revisiting. First entry under the new `docs/architecture/decisions/` ADR home.
46
+ - **`spec/examples/`** — new spec subdirectory with hermetic unit tests for both recipe files. Tests use captured GitHub API fixtures under `spec/examples/fixtures/`; no network access in CI.
47
+
48
+ ### Documentation
49
+
50
+ - The "Recipes" concept is introduced as a deliberately opt-in pattern: example code that demonstrates how to build on textus primitives without committing the core surface to the underlying responsibility. The ADR explains why this is preferable to promoting the recipe into a builtin or a new CLI verb today.
51
+
11
52
  ## 0.10.3 — Documentation refresh and legacy-code removal (2026-05-23)
12
53
 
13
54
  Patch release. Two pieces of work: (1) docs describe current state only — every reference to pre-0.9.2 zone names, pre-0.10.2 sentinel layout, pre-0.5 audit-log format, and other version-history annotations is stripped from user-facing docs; (2) the corresponding backward-compatibility code paths are deleted from `lib/`. The wire protocol stays `textus/2`. Callers conforming to the current SPEC are unaffected; callers carrying obsolete config now hit silent drops or parse failures instead of helpful migration messages.
@@ -25,7 +66,7 @@ Patch release. Two pieces of work: (1) docs describe current state only — ever
25
66
 
26
67
  ### Documentation
27
68
 
28
- - **`docs/plans/`** is now signposted in `CONTRIBUTING.md` as design history, not current documentation.
69
+ - **`CONTRIBUTING.md`** now points readers at SPEC / ARCHITECTURE / `docs/` / CHANGELOG as the sources of truth. Per-release implementation plans are kept locally by maintainers and are no longer signposted in public docs.
29
70
  - **README, SPEC, ARCHITECTURE, docs/zones, docs/events, docs/conventions, examples/claude-plugin/README** — stripped `(0.8.2+)`, `(0.9.0+)`, `(0.9.2)`, `(v1.0)`, `(v1.1)`, `(v1.2)`, `(v0.3)` annotations from headings, parentheticals, and inline notes. Removed "Renamed in 0.9.2" / "Pre-0.9.2 stores" / "New in 0.9.0" / "Backward compatibility (v0.5)" callouts. Example code that used pre-0.9.2 zone names (`canon`, `intake`, `pending`, `derived`) now uses current names (`identity`, `inbox`, `review`, `output`).
30
71
  - **`docs/events.md`** — header count corrected to "15 events: 3 RPC and 12 pub-sub" (previously read "12 events", with refresh\_\* mentioned in subtext); stale Linear manifest example updated to use top-level `policies:` block.
31
72
  - **SPEC.md §10.2** — removed `legacy_intake_fields` from the builtin doctor-check list.
data/README.md CHANGED
@@ -85,68 +85,14 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
85
85
 
86
86
  Symlink-mode publish was removed; publish is `FileUtils.cp` + sentinel. Sentinels for published files live under `.textus/sentinels/<target_rel>.textus-managed.json` so consumer directories stay clean. Legacy sibling sentinels auto-migrate on next publish.
87
87
 
88
- ## CLI verbs
89
-
90
- All verbs accept `--format=json` and return the envelope defined in SPEC §8. Write verbs require `--as=<role>` (role resolution: `--as` → `TEXTUS_ROLE` env → `.textus/role` file → default `human`).
91
-
92
- **Read:**
93
-
94
- | Verb | Purpose |
95
- |---|---|
96
- | `intro` | Store orientation: zones, entries, hooks, write flows, CLI map |
97
- | `list [--prefix=K] [--zone=Z]` | Enumerate keys |
98
- | `where K` | Resolve a key to its filesystem path |
99
- | `get K` | Full envelope (frontmatter, body, uid, etag, format) |
100
- | `schema show K` | Schema bound to an entry |
101
- | `freshness [--prefix=K] [--zone=Z]` | Per-entry status (fresh / stale / never_refreshed / no_policy) against `policies:` |
102
- | `audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]` | Query `.textus/audit.log` |
103
- | `blame KEY` | Audit rows joined with git commit metadata |
104
- | `policy list` / `policy explain KEY` | Dump effective policies / per-slot winners for one key |
105
- | `deps K` / `rdeps K` | Forward / reverse projection dependencies |
106
- | `published` | List `publish_to:` targets and their backing keys |
107
- | `doctor --check=schema_violations` | Validate every entry against its schema |
108
- | `hook list [--event=E]` | Registered hooks grouped by event (intake, reduce, check, put, deleted, refreshed, built, accepted, published, mv, reject, loaded, refresh_began, refresh_failed, refresh_detached) |
109
-
110
- **Write:**
111
-
112
- | Verb | Role |
113
- |---|---|
114
- | `put K --stdin --as=R [--action=NAME]` | per zone |
115
- | `hook run NAME [--key=val] [--as=R]` | per zone written (invoke a registered intake hook) |
116
- | `delete K --if-etag=E --as=R` | per zone |
117
- | `refresh K --as=script` | per zone (typically `script`) |
118
- | `key mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
119
- | `build [--prefix=K] [--dry-run]` | `build` |
120
- | `accept K --as=human` | `human` only |
121
- | `reject K --as=human` | `human` only (discards a pending proposal; fires `:reject`) |
122
-
123
- **Health & maintenance:**
124
-
125
- | Verb | Purpose |
126
- |---|---|
127
- | `doctor` | Health checks (manifest, schemas, templates, hooks, illegal keys, sentinels, audit log, policy ambiguity, handler allowlist, legacy intake fields); `ok: true` when clean |
128
- | `key migrate [--dry-run]` | Rename files whose basenames violate the strict key grammar |
129
-
130
- **Scaffolding (human-only):**
131
-
132
- | Verb | Purpose |
133
- |---|---|
134
- | `init` | Scaffold a fresh `.textus/` |
135
- | `schema init NAME` | Stub a schema |
136
- | `schema diff NAME` | Compare a schema against entries that claim it |
137
- | `schema migrate NAME [--rename=OLD:NEW]` | Rewrite frontmatter keys across affected entries |
138
-
139
- ## Zones and roles
140
-
141
- | Zone | `writable_by` | Purpose |
142
- |---|---|---|
143
- | `identity` | `[human]` | Identity, voice, decisions — slow-changing |
144
- | `working` | `[human, ai, script]` | Active project state |
145
- | `inbox` | `[script]` | Declared external inputs (actions) |
146
- | `review` | `[ai, human]` | AI proposals; humans run `textus accept` to apply |
147
- | `output` | `[build]` | Computed outputs from `textus build` |
148
-
149
- Mismatches return `write_forbidden` with a hint naming the role that *would* be allowed. Every write records the resolved role in `.textus/audit.log`.
88
+ ## CLI and zones
89
+
90
+ All verbs accept `--format=json` and return the envelope defined in [SPEC §8](SPEC.md). Write verbs require `--as=<role>` (role resolution: `--as` → `TEXTUS_ROLE` env → `.textus/role` file → default `human`).
91
+
92
+ - Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
93
+ - Zone semantics and the role/`writable_by` mapping live in [SPEC §5](SPEC.md), with a tutorial expansion in [`docs/zones.md`](docs/zones.md).
94
+
95
+ `textus intro` prints the same information for the current store: zones, entry families with schemas, registered hooks, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.
150
96
 
151
97
  ## Compute and publish
152
98
 
data/SPEC.md CHANGED
@@ -144,6 +144,18 @@ Zone names are conventional — the manifest is the source of truth for write pe
144
144
 
145
145
  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.
146
146
 
147
+ **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:
148
+
149
+ ```yaml
150
+ - key: skills
151
+ path: skills
152
+ zone: skills
153
+ nested: true
154
+ index_filename: SKILL.md
155
+ ```
156
+
157
+ 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).
158
+
147
159
  **Per-leaf publishing (`publish_each:`).** A nested manifest entry MAY declare `publish_each:` to byte-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):
148
160
 
149
161
  | Variable | Value |
@@ -226,6 +238,44 @@ If `template` is given, it names a Mustache template under `.textus/templates/`.
226
238
 
227
239
  No partials. No lambdas. No HTML escaping (output is raw text, intended for Markdown). Template recursion depth is bounded at 8; exceeding the limit is an error.
228
240
 
241
+ ### 5.2.1 Externally-generated derived entries (`generator:`)
242
+
243
+ A derived entry that is produced by a build tool *outside* textus — `rake`, `just`, a shell script, anything — declares `generator:` instead of `projection:`. textus does **not** execute the command (consistent with §2); the external runner is responsible for writing the file. textus records `sources:` so `textus freshness` can compare source mtimes against the derived file's `_meta.generated.at` and report staleness.
244
+
245
+ ```yaml
246
+ - key: output.catalogs.skills
247
+ path: output/catalogs/skills.md
248
+ zone: output
249
+ owner: build:catalog-skills
250
+ generator:
251
+ command: "rake catalog:skills" # informational; the runner invokes it
252
+ sources: # dotted keys OR repo-relative paths
253
+ - working.projects
254
+ - working.network
255
+ ```
256
+
257
+ **`sources:`** is a list. Each element is either a dotted key prefix (matched against manifest entries) or a filesystem path (relative to the repo root, or absolute). For each key prefix, every matching entry's file mtime is checked. For each path, file or directory mtime is checked.
258
+
259
+ **`command:`** is recorded in the staleness row's `generator` field but never executed. It exists so `textus freshness` output can carry a hint about how to refresh.
260
+
261
+ **Freshness contract.** An entry with `generator:` is reported by `textus freshness` as `stale` when:
262
+ - The derived file does not exist, OR
263
+ - `_meta.generated.at` is missing or unparseable, OR
264
+ - Any `sources:` element has been modified after `_meta.generated.at`.
265
+
266
+ **Frontmatter contract.** The external runner is responsible for writing the `generated:` frontmatter block when it produces the file:
267
+
268
+ ```yaml
269
+ generated:
270
+ by: "rake catalog:skills"
271
+ at: "2026-05-25T12:00:00Z"
272
+ from: [working.projects, working.network]
273
+ ```
274
+
275
+ `generated.from` SHOULD match `generator.sources` — they're the same list, recorded twice so a diff proves what was actually consumed.
276
+
277
+ `generator:` and `projection:` are alternatives — typically only one is set per entry. Templates are not required when `generator:` is declared: the runner produces the bytes directly.
278
+
229
279
  ### 5.3 Publish layer (`publish_to:`)
230
280
 
231
281
  A derived entry MAY declare `publish_to:` in its frontmatter, listing one or more destination paths relative to the project root:
data/docs/conventions.md CHANGED
@@ -44,34 +44,66 @@ The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it
44
44
 
45
45
  Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
46
46
 
47
- ## Derived entries and build runners
47
+ ## Derived entries
48
48
 
49
- **Always** declare `generator:` on derived entries that participate in any build pipeline. Without it, `textus freshness` cannot help — the entry is just an opaque file.
49
+ textus supports two shapes for derived entries:
50
+
51
+ **`projection:`** — textus computes the entry on `textus build` from other store entries. Declarative; nothing shells out.
50
52
 
51
53
  ```yaml
52
- - key: output.catalogs.skills
53
- path: output/catalogs/skills
54
+ - key: output.catalogs.people
55
+ path: output/catalogs/people.md
54
56
  zone: output
55
57
  schema: null
58
+ owner: build:catalog-people
59
+ projection:
60
+ select: working.network.org # prefix or list of prefixes
61
+ pluck: [name, relationship, org]
62
+ sort_by: name
63
+ template: people.mustache # under .textus/templates/
64
+ publish_to: [docs/people.md] # optional repo-relative byte-copy targets
65
+ ```
66
+
67
+ **`generator:`** — 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`.
68
+
69
+ ```yaml
70
+ - key: output.catalogs.skills
71
+ path: output/catalogs/skills.md
72
+ zone: output
56
73
  owner: build:catalog-skills
57
74
  generator:
58
- command: "rake catalog:skills"
59
- sources:
60
- - working.projects
61
- - working.network
75
+ command: "rake catalog:skills" # informational; the runner invokes it
76
+ sources: [working.projects, working.network]
77
+ ```
78
+
79
+ The build runner is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `generator.sources` — same list, recorded twice so a diff proves what was consumed.
80
+
81
+ Full contract for both shapes is in [`../SPEC.md` §5.2 and §5.2.1](../SPEC.md). Reducers (`projection.reduce:`) and per-leaf publishing (`publish_each:`) are also covered there.
82
+
83
+ ## Intake and freshness
84
+
85
+ External inputs land via `:intake` hooks, not shell commands. Each inbox entry names a registered handler; refresh is on demand:
86
+
87
+ ```sh
88
+ textus refresh inbox.notion.roadmap --as=script
89
+ textus refresh-stale --zone=inbox --as=script # everything past its TTL
90
+ ```
91
+
92
+ Freshness budgets live in the top-level `policies:` block, matched by glob:
93
+
94
+ ```yaml
95
+ policies:
96
+ - match: inbox.notion.**
97
+ refresh: { ttl: 6h, on_stale: warn } # warn | sync | timed_sync
62
98
  ```
63
99
 
64
- **The build runner is responsible for writing the `generated:` frontmatter block** when it regenerates. The gem will never synthesize it. A typical lefthook / rake / just integration looks like:
100
+ A typical scheduled-refresh integration shells the `refresh-stale` sweep itself:
65
101
 
66
102
  ```sh
67
- textus freshness --format=json \
68
- | jq -r '.rows[] | select(.status == "stale") | .key' \
69
- | while read key; do
70
- textus refresh "$key" --as=script
71
- done
103
+ textus refresh-stale --zone=inbox --as=script # in cron / CI
72
104
  ```
73
105
 
74
- `generated.from` SHOULD match `generator.sources` from the manifest they're the same list, recorded in two places so a diffable file proves what was actually consumed.
106
+ See [`./zones.md` §6](zones.md) for the full intake contract and [`./events.md`](events.md) for writing custom handlers.
75
107
 
76
108
  ## Body content
77
109
 
@@ -17,8 +17,7 @@ module Textus
17
17
  end
18
18
 
19
19
  @ctx.store.writer.delete_envelope_from_disk(
20
- key, if_etag: if_etag, as: @ctx.role,
21
- correlation_id: @ctx.correlation_id
20
+ key, ctx: @ctx, if_etag: if_etag
22
21
  )
23
22
 
24
23
  unless suppress_events
@@ -19,12 +19,9 @@ module Textus
19
19
  envelope = @ctx.store.writer.write_envelope_to_disk(
20
20
  key,
21
21
  mentry: mentry,
22
- meta: meta,
23
- body: body,
24
- content: content,
22
+ payload: Textus::Store::Writer::Payload.new(meta: meta, body: body, content: content),
23
+ ctx: @ctx,
25
24
  if_etag: if_etag,
26
- as: @ctx.role,
27
- correlation_id: @ctx.correlation_id,
28
25
  )
29
26
 
30
27
  unless suppress_events
@@ -7,7 +7,7 @@ module Textus
7
7
  attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
8
8
  :projection, :template, :publish_to, :publish_each,
9
9
  :intake_handler, :intake_config,
10
- :events, :inject_intro
10
+ :events, :inject_intro, :index_filename
11
11
 
12
12
  def initialize(manifest, raw)
13
13
  @manifest = manifest
@@ -25,6 +25,7 @@ module Textus
25
25
  @publish_each = raw["publish_each"]
26
26
  @events = raw["events"] || {}
27
27
  @inject_intro = raw["inject_intro"] == true
28
+ @index_filename = raw["index_filename"]
28
29
  @format = resolve_format!(raw["format"])
29
30
 
30
31
  validate_events!
@@ -32,6 +33,7 @@ module Textus
32
33
  validate_format_matrix!
33
34
  validate_publish_each!
34
35
  validate_inject_intro!
36
+ validate_index_filename!
35
37
  end
36
38
 
37
39
  # Resolves the per-leaf target path (relative to repo root) for a full
@@ -66,6 +68,37 @@ module Textus
66
68
 
67
69
  private
68
70
 
71
+ # `index_filename:` makes a nested entry treat a fixed basename (e.g.
72
+ # `SKILL.md`) as the per-directory row. The directory path becomes the
73
+ # key suffix; sibling files are not enumerated. Allows projecting
74
+ # spec-mandated filenames that would otherwise be rejected by the
75
+ # lowercase-only key segment grammar.
76
+ def validate_index_filename!
77
+ return if @index_filename.nil?
78
+
79
+ raise UsageError.new("entry '#{@key}': index_filename requires nested: true") unless @nested
80
+ unless @index_filename.is_a?(String) && !@index_filename.empty?
81
+ raise UsageError.new("entry '#{@key}': index_filename must be a non-empty string")
82
+ end
83
+ if @index_filename.include?("/") || File.basename(@index_filename) != @index_filename
84
+ raise UsageError.new("entry '#{@key}': index_filename must be a bare basename (no slashes)")
85
+ end
86
+
87
+ ext = File.extname(@index_filename)
88
+ inferred = Manifest::EXT_TO_FORMAT[ext]
89
+ if inferred.nil?
90
+ raise UsageError.new(
91
+ "entry '#{@key}': index_filename #{@index_filename.inspect} has unknown extension #{ext.inspect}",
92
+ )
93
+ end
94
+ return if inferred == @format
95
+
96
+ raise UsageError.new(
97
+ "entry '#{@key}': index_filename extension #{ext.inspect} implies format #{inferred.inspect}, " \
98
+ "but entry format is #{@format.inspect}",
99
+ )
100
+ end
101
+
69
102
  def zone_writers
70
103
  @manifest.zone_writers(@zone)
71
104
  rescue UsageError => e
@@ -76,8 +76,12 @@ module Textus
76
76
  else
77
77
  raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless entry.nested
78
78
 
79
- primary_ext = Textus::Entry.for_format(entry.format).extensions.first
80
- path = File.join(@root, "zones", entry.path, *remaining) + primary_ext
79
+ path = if entry.index_filename
80
+ File.join(@root, "zones", entry.path, *remaining, entry.index_filename)
81
+ else
82
+ primary_ext = Textus::Entry.for_format(entry.format).extensions.first
83
+ File.join(@root, "zones", entry.path, *remaining) + primary_ext
84
+ end
81
85
  [entry, path, remaining]
82
86
  end
83
87
  end
@@ -96,39 +100,11 @@ module Textus
96
100
 
97
101
  # Enumerate all entry files reachable through the manifest. Returns
98
102
  # [{ key:, path:, manifest_entry: }, ...]
99
- # rubocop:disable Metrics/AbcSize
100
103
  def enumerate(prefix: nil)
101
- out = []
102
- @entries.each do |entry|
103
- if entry.nested
104
- base = File.join(@root, "zones", entry.path)
105
- next unless File.directory?(base)
106
-
107
- glob_pattern = nested_glob(entry.format)
108
- Dir.glob(File.join(base, glob_pattern)).each do |fp|
109
- rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
110
- stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
111
- segs = stripped.split("/").reject(&:empty?)
112
- next if segs.empty?
113
-
114
- illegal = segs.find { |s| !valid_segment?(s) }
115
- if illegal
116
- warn("textus: skipping illegal key segment '#{illegal}' at #{fp} — run 'textus key migrate --dry-run'")
117
- next
118
- end
119
-
120
- full_key = (entry.key.split(".") + segs).join(".")
121
- out << { key: full_key, path: fp, manifest_entry: entry }
122
- end
123
- else
124
- fp = resolve_leaf_path(entry)
125
- out << { key: entry.key, path: fp, manifest_entry: entry } if File.exist?(fp)
126
- end
127
- end
104
+ out = @entries.flat_map { |entry| entry.nested ? enumerate_nested(entry) : enumerate_leaf(entry) }
128
105
  out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
129
106
  out.sort_by { |row| row[:key] }
130
107
  end
131
- # rubocop:enable Metrics/AbcSize
132
108
 
133
109
  def validate_key!(key)
134
110
  raise UsageError.new("empty key") if key.nil? || key.empty?
@@ -138,6 +114,34 @@ module Textus
138
114
 
139
115
  private
140
116
 
117
+ def enumerate_leaf(entry)
118
+ fp = resolve_leaf_path(entry)
119
+ File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
120
+ end
121
+
122
+ def enumerate_nested(entry)
123
+ base = File.join(@root, "zones", entry.path)
124
+ return [] unless File.directory?(base)
125
+
126
+ glob_pattern = entry.index_filename ? "**/#{entry.index_filename}" : nested_glob(entry.format)
127
+ Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
128
+ end
129
+
130
+ def nested_row_for(entry, base, path)
131
+ rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
132
+ stripped = entry.index_filename ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
133
+ segs = stripped.split("/").reject { |s| s.empty? || s == "." }
134
+ return nil if segs.empty?
135
+
136
+ illegal = segs.find { |s| !valid_segment?(s) }
137
+ if illegal
138
+ warn("textus: skipping illegal key segment '#{illegal}' at #{path} — run 'textus key migrate --dry-run'")
139
+ return nil
140
+ end
141
+
142
+ { key: (entry.key.split(".") + segs).join("."), path: path, manifest_entry: entry }
143
+ end
144
+
141
145
  def valid_segment?(seg)
142
146
  return false if seg.nil? || seg.empty?
143
147
  return false if seg.length > Key::Grammar::MAX_SEGMENT_LEN
@@ -2,8 +2,12 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  class Store
5
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
6
5
  class Mover
6
+ MovePlan = Data.define(
7
+ :old_key, :new_key, :old_path, :new_path,
8
+ :new_mentry, :uid, :etag_before, :as
9
+ )
10
+
7
11
  def initialize(store:, reader:, writer:, manifest:, audit_log:)
8
12
  @store = store
9
13
  @reader = reader
@@ -13,6 +17,22 @@ module Textus
13
17
  end
14
18
 
15
19
  def call(old_key, new_key, as: Role::DEFAULT, dry_run: false, correlation_id: nil)
20
+ plan, pre_env = prepare_plan(old_key, new_key, as: as)
21
+ return dry_run_result(plan) if dry_run
22
+
23
+ plan = ensure_uid!(plan, pre_env: pre_env)
24
+ etag_after = perform_move!(plan)
25
+ new_envelope = record_move(plan, etag_after: etag_after, correlation_id: correlation_id)
26
+ success_result(plan, new_envelope: new_envelope)
27
+ end
28
+
29
+ private
30
+
31
+ # Validates inputs, resolves manifest entries, and reads the source
32
+ # envelope. Returns [MovePlan, pre_envelope]; the pre_envelope is only
33
+ # needed by ensure_uid! and is threaded separately to keep MovePlan
34
+ # focused on the planned operation.
35
+ def prepare_plan(old_key, new_key, as:)
16
36
  @manifest.validate_key!(old_key)
17
37
  @manifest.validate_key!(new_key)
18
38
  raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
@@ -21,81 +41,103 @@ module Textus
21
41
  raise UnknownKey.new(old_key) unless File.exist?(old_path)
22
42
 
23
43
  new_mentry, new_path, = @manifest.resolve(new_key)
44
+ validate_zone_and_format!(old_mentry, new_mentry)
45
+ validate_writer!(old_mentry, old_key, as)
46
+ raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
24
47
 
48
+ pre_env = @reader.get(old_key)
49
+ plan = MovePlan.new(
50
+ old_key: old_key, new_key: new_key,
51
+ old_path: old_path, new_path: new_path,
52
+ new_mentry: new_mentry,
53
+ uid: pre_env["uid"], etag_before: pre_env["etag"], as: as
54
+ )
55
+ [plan, pre_env]
56
+ end
57
+
58
+ def validate_zone_and_format!(old_mentry, new_mentry)
25
59
  if old_mentry.zone != new_mentry.zone
26
60
  raise UsageError.new(
27
61
  "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
28
62
  "Use put+delete for cross-zone moves.",
29
63
  )
30
64
  end
31
- if old_mentry.format != new_mentry.format
32
- raise UsageError.new(
33
- "mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.",
34
- )
35
- end
65
+ return if old_mentry.format == new_mentry.format
36
66
 
37
- writers = @manifest.zone_writers(old_mentry.zone)
38
- raise WriteForbidden.new(old_key, old_mentry.zone, writers: writers) unless writers.include?(as)
67
+ raise UsageError.new(
68
+ "mv: format mismatch (#{old_mentry.format} #{new_mentry.format}); refusing.",
69
+ )
70
+ end
39
71
 
40
- raise UsageError.new("mv: target '#{new_key}' already exists at #{new_path}") if File.exist?(new_path)
72
+ def validate_writer!(mentry, key, as)
73
+ writers = @manifest.zone_writers(mentry.zone)
74
+ return if writers.include?(as)
41
75
 
42
- # Mint uid before the move so the audit row carries it.
43
- pre_env = @reader.get(old_key)
44
- current_uid = pre_env["uid"]
45
- etag_before = pre_env["etag"]
46
-
47
- if dry_run
48
- return {
49
- "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
50
- "from_key" => old_key, "to_key" => new_key,
51
- "from_path" => old_path, "to_path" => new_path,
52
- "uid" => current_uid
53
- }
54
- end
76
+ raise WriteForbidden.new(key, mentry.zone, writers: writers)
77
+ end
55
78
 
56
- if current_uid.nil?
57
- # Write the uid in place first so the source file carries it before mv.
58
- pre_env = @writer.put(old_key,
59
- meta: pre_env["_meta"],
60
- body: pre_env["body"],
61
- content: pre_env["content"],
62
- as: as,
63
- suppress_events: true)
64
- current_uid = pre_env["uid"]
65
- etag_before = pre_env["etag"]
66
- end
79
+ def ensure_uid!(plan, pre_env:)
80
+ return plan if plan.uid
67
81
 
68
- FileUtils.mkdir_p(File.dirname(new_path))
69
- FileUtils.mv(old_path, new_path)
70
- rewrite_name_for_mv!(new_mentry, new_path, new_key)
71
- etag_after = Etag.for_file(new_path)
82
+ env = @writer.put(
83
+ plan.old_key,
84
+ meta: pre_env["_meta"],
85
+ body: pre_env["body"],
86
+ content: pre_env["content"],
87
+ as: plan.as,
88
+ suppress_events: true,
89
+ )
90
+ plan.with(uid: env["uid"], etag_before: env["etag"])
91
+ end
92
+
93
+ def perform_move!(plan)
94
+ FileUtils.mkdir_p(File.dirname(plan.new_path))
95
+ FileUtils.mv(plan.old_path, plan.new_path)
96
+ rewrite_name_for_mv!(plan.new_mentry, plan.new_path, plan.new_key)
97
+ Etag.for_file(plan.new_path)
98
+ end
72
99
 
100
+ def record_move(plan, etag_after:, correlation_id:)
73
101
  extras = {
74
- "from_key" => old_key, "to_key" => new_key,
75
- "from_path" => old_path, "to_path" => new_path,
76
- "uid" => current_uid
102
+ "from_key" => plan.old_key, "to_key" => plan.new_key,
103
+ "from_path" => plan.old_path, "to_path" => plan.new_path,
104
+ "uid" => plan.uid
77
105
  }
78
106
  extras["correlation_id"] = correlation_id if correlation_id
79
107
 
80
108
  @audit_log.append(
81
- role: as, verb: "mv", key: new_key,
82
- etag_before: etag_before, etag_after: etag_after,
109
+ role: plan.as, verb: "mv", key: plan.new_key,
110
+ etag_before: plan.etag_before, etag_after: etag_after,
83
111
  extras: extras
84
112
  )
113
+ new_envelope = @reader.get(plan.new_key)
114
+ @store.fire_event(
115
+ :mv,
116
+ key: plan.new_key, from_key: plan.old_key, to_key: plan.new_key,
117
+ envelope: new_envelope
118
+ )
119
+ new_envelope
120
+ end
121
+
122
+ def dry_run_result(plan)
123
+ {
124
+ "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
125
+ "from_key" => plan.old_key, "to_key" => plan.new_key,
126
+ "from_path" => plan.old_path, "to_path" => plan.new_path,
127
+ "uid" => plan.uid
128
+ }
129
+ end
85
130
 
86
- new_envelope = @reader.get(new_key)
87
- @store.fire_event(:mv, key: new_key, from_key: old_key, to_key: new_key, envelope: new_envelope)
131
+ def success_result(plan, new_envelope:)
88
132
  {
89
133
  "protocol" => PROTOCOL, "ok" => true,
90
- "from_key" => old_key, "to_key" => new_key,
91
- "from_path" => old_path, "to_path" => new_path,
92
- "uid" => current_uid,
134
+ "from_key" => plan.old_key, "to_key" => plan.new_key,
135
+ "from_path" => plan.old_path, "to_path" => plan.new_path,
136
+ "uid" => plan.uid,
93
137
  "envelope" => new_envelope
94
138
  }
95
139
  end
96
140
 
97
- private
98
-
99
141
  # If the moved file carries a `name:` field (markdown) or `_meta.name`
100
142
  # (json/yaml), rewrite it to the new basename so enforce_name_match! stays
101
143
  # happy on the next read. Only touches the bytes when name actually changes.
@@ -121,6 +163,5 @@ module Textus
121
163
  end
122
164
  end
123
165
  end
124
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
125
166
  end
126
167
  end
@@ -0,0 +1,88 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ class Store
5
+ class Staleness
6
+ # Reports staleness for generator-zone entries — derived files whose
7
+ # generator's listed sources have been modified more recently than the
8
+ # entry's `_meta.generated.at` timestamp. Returns an Array of row hashes
9
+ # (possibly empty) per entry.
10
+ class GeneratorCheck
11
+ def initialize(manifest:)
12
+ @manifest = manifest
13
+ end
14
+
15
+ def rows_for(mentry)
16
+ return [] unless mentry.in_generator_zone?
17
+
18
+ gen = mentry.generator
19
+ return [] unless gen
20
+
21
+ path = Textus::Key::Path.resolve(@manifest, mentry)
22
+ return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
23
+
24
+ parsed = Entry.for_format(mentry.format).parse(File.binread(path), path: path)
25
+ generated_at = parsed["_meta"].dig("generated", "at")
26
+ return [stale_row(mentry, path, "missing generated.at frontmatter")] unless generated_at
27
+
28
+ gen_time = parse_time(generated_at)
29
+ return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
30
+
31
+ offender = newest_source_after(gen, gen_time)
32
+ return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
33
+
34
+ []
35
+ end
36
+
37
+ private
38
+
39
+ def parse_time(str)
40
+ Time.parse(str.to_s)
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ def newest_source_after(gen, gen_time)
46
+ Array(gen["sources"]).each do |src|
47
+ offender = check_source(src, gen_time)
48
+ return offender if offender
49
+ end
50
+ nil
51
+ end
52
+
53
+ def check_source(src, gen_time)
54
+ if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
55
+ @manifest.enumerate(prefix: src).each do |row|
56
+ return src if File.mtime(row[:path]) > gen_time
57
+ end
58
+ nil
59
+ else
60
+ check_filesystem_source(src, gen_time)
61
+ end
62
+ end
63
+
64
+ def check_filesystem_source(src, gen_time)
65
+ abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
66
+ if File.directory?(abs)
67
+ Dir.glob(File.join(abs, "**", "*")).each do |fp|
68
+ next unless File.file?(fp)
69
+ return src if File.mtime(fp) > gen_time
70
+ end
71
+ nil
72
+ elsif File.exist?(abs) && File.mtime(abs) > gen_time
73
+ src
74
+ end
75
+ end
76
+
77
+ def stale_row(mentry, path, reason)
78
+ {
79
+ "key" => mentry.key,
80
+ "path" => path,
81
+ "generator" => mentry.generator,
82
+ "reason" => reason,
83
+ }
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,46 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ class Store
5
+ class Staleness
6
+ # Reports TTL-exceeded staleness for intake-handler entries. Returns an
7
+ # Array of row hashes (possibly empty) per entry.
8
+ class IntakeCheck
9
+ def initialize(manifest:)
10
+ @manifest = manifest
11
+ end
12
+
13
+ def rows_for(mentry)
14
+ return [] unless mentry.intake_handler
15
+
16
+ ttl = @manifest.policies_for(mentry.key).refresh&.ttl_seconds
17
+ return [] unless ttl
18
+
19
+ path = Textus::Key::Path.resolve(@manifest, mentry)
20
+ return [row(mentry, path, "never refreshed")] unless File.exist?(path)
21
+
22
+ meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
23
+ last_str = meta["last_refreshed_at"]
24
+ return [row(mentry, path, "never refreshed (no last_refreshed_at)")] if last_str.nil?
25
+
26
+ last = parse_time(last_str)
27
+ return [row(mentry, path, "ttl exceeded (#{ttl}s)")] if last.nil? || (Time.now - last) > ttl
28
+
29
+ []
30
+ end
31
+
32
+ private
33
+
34
+ def parse_time(str)
35
+ Time.parse(str.to_s)
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ def row(mentry, path, reason)
41
+ { "key" => mentry.key, "path" => path, "handler" => mentry.intake_handler, "reason" => reason }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,121 +1,26 @@
1
- require "time"
2
-
3
1
  module Textus
4
2
  class Store
5
- # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
6
3
  class Staleness
7
4
  def initialize(manifest:)
8
5
  @manifest = manifest
6
+ @generator_check = GeneratorCheck.new(manifest: manifest)
7
+ @intake_check = IntakeCheck.new(manifest: manifest)
9
8
  end
10
9
 
11
10
  def call(prefix: nil, zone: nil)
12
- out = []
13
- @manifest.entries.each do |mentry|
14
- next unless mentry.in_generator_zone?
15
- next if zone && mentry.zone != zone
16
-
17
- gen = mentry.generator
18
- next unless gen
19
- next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
20
-
21
- path = Textus::Key::Path.resolve(@manifest, mentry)
22
-
23
- unless File.exist?(path)
24
- out << stale_row(mentry, path, "derived entry has never been generated")
25
- next
26
- end
27
-
28
- raw = File.binread(path)
29
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
30
- generated_at = parsed["_meta"].dig("generated", "at")
31
- unless generated_at
32
- out << stale_row(mentry, path, "missing generated.at frontmatter")
33
- next
34
- end
35
- gen_time = begin
36
- Time.parse(generated_at.to_s)
37
- rescue StandardError
38
- nil
39
- end
40
- unless gen_time
41
- out << stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")
42
- next
43
- end
44
-
45
- offender = newest_source_after(gen, gen_time)
46
- out << stale_row(mentry, path, "source '#{offender}' modified after generated.at") if offender
47
- end
48
-
49
- @manifest.entries.each do |mentry|
50
- next unless mentry.intake_handler
51
- next if zone && mentry.zone != zone
52
- next if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
53
-
54
- policy_set = @manifest.policies_for(mentry.key)
55
- ttl = policy_set.refresh&.ttl_seconds
56
- next unless ttl
57
-
58
- path = Textus::Key::Path.resolve(@manifest, mentry)
59
-
60
- unless File.exist?(path)
61
- out << intake_stale_row(mentry, path, "never refreshed")
62
- next
63
- end
64
-
65
- meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
66
- last_str = meta["last_refreshed_at"]
67
- if last_str.nil?
68
- out << intake_stale_row(mentry, path, "never refreshed (no last_refreshed_at)")
69
- next
70
- end
71
-
72
- last = begin
73
- Time.parse(last_str.to_s)
74
- rescue StandardError
75
- nil
76
- end
77
- out << intake_stale_row(mentry, path, "ttl exceeded (#{ttl}s)") if last.nil? || (Time.now - last) > ttl
78
- end
79
-
80
- out
11
+ @manifest.entries
12
+ .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
13
+ .flat_map { |m| @generator_check.rows_for(m) + @intake_check.rows_for(m) }
81
14
  end
82
15
 
83
16
  private
84
17
 
85
- def newest_source_after(gen, gen_time)
86
- Array(gen["sources"]).each do |src|
87
- if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
88
- @manifest.enumerate(prefix: src).each do |row|
89
- return src if File.mtime(row[:path]) > gen_time
90
- end
91
- else
92
- abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
93
- if File.directory?(abs)
94
- Dir.glob(File.join(abs, "**", "*")).each do |fp|
95
- next unless File.file?(fp)
96
- return src if File.mtime(fp) > gen_time
97
- end
98
- elsif File.exist?(abs)
99
- return src if File.mtime(abs) > gen_time
100
- end
101
- end
102
- end
103
- nil
104
- end
105
-
106
- def intake_stale_row(mentry, path, reason)
107
- { "key" => mentry.key, "path" => path, "handler" => mentry.intake_handler, "reason" => reason }
108
- end
18
+ def entry_matches?(mentry, prefix:, zone:)
19
+ return false if zone && mentry.zone != zone
20
+ return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
109
21
 
110
- def stale_row(mentry, path, reason)
111
- {
112
- "key" => mentry.key,
113
- "path" => path,
114
- "generator" => mentry.generator,
115
- "reason" => reason,
116
- }
22
+ true
117
23
  end
118
24
  end
119
- # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity, Metrics/BlockLength
120
25
  end
121
26
  end
@@ -2,8 +2,9 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  class Store
5
- # rubocop:disable Metrics/ParameterLists
6
5
  class Writer
6
+ Payload = Data.define(:meta, :body, :content)
7
+
7
8
  def initialize(store)
8
9
  @store = store
9
10
  @manifest = store.manifest
@@ -11,28 +12,30 @@ module Textus
11
12
  end
12
13
 
13
14
  # Backward-compat shim — orchestration now lives in Application::Writes::Put.
15
+ # rubocop:disable Metrics/ParameterLists
14
16
  def put(key, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, suppress_events: false)
15
17
  ctx = Textus::Application::Context.new(store: @store, role: as)
16
18
  Textus::Application::Writes::Put.new(ctx: ctx, bus: @store.bus).call(
17
19
  key, meta: meta, body: body, content: content, if_etag: if_etag, suppress_events: suppress_events
18
20
  )
19
21
  end
22
+ # rubocop:enable Metrics/ParameterLists
20
23
 
21
24
  # Pure I/O: validate, serialize, etag-check, write to disk, audit. No
22
25
  # permission check and no event firing — those are handled by the caller
23
26
  # (Application::Writes::Put).
24
- def write_envelope_to_disk(key, mentry:, meta: nil, body: nil, content: nil, if_etag: nil, as: Role::DEFAULT, correlation_id: nil)
27
+ def write_envelope_to_disk(key, mentry:, payload:, ctx:, if_etag: nil)
25
28
  _, path, = @manifest.resolve(key)
26
29
 
27
- meta ||= {}
30
+ meta = payload.meta || {}
28
31
  strategy = Entry.for_format(mentry.format)
29
32
 
30
33
  existing_uid = existing_uid_for(mentry, path)
31
- meta, content = ensure_uid(mentry.format, meta, content, existing_uid)
34
+ meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
32
35
 
33
36
  bytes, eff_meta, eff_body, eff_content = serialize_for_put(
34
37
  mentry: mentry, path: path, strategy: strategy,
35
- meta: meta, body: body, content: content
38
+ meta: meta, body: payload.body, content: content
36
39
  )
37
40
 
38
41
  enforce_name_match!(path, eff_meta, mentry.format)
@@ -52,9 +55,9 @@ module Textus
52
55
  File.binwrite(path, bytes)
53
56
  etag_after = Etag.for_bytes(bytes)
54
57
  @store.audit_log.append(
55
- role: as, verb: "put", key: key,
58
+ role: ctx.role, verb: "put", key: key,
56
59
  etag_before: etag_before, etag_after: etag_after,
57
- extras: correlation_id ? { "correlation_id" => correlation_id } : nil
60
+ extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
58
61
  )
59
62
  Envelope.build(
60
63
  key: key, mentry: mentry, path: path,
@@ -129,7 +132,7 @@ module Textus
129
132
  # Pure I/O: resolve path, validate etag, delete from disk, audit. No
130
133
  # permission check and no event firing — those are handled by the caller
131
134
  # (Application::Writes::Delete).
132
- def delete_envelope_from_disk(key, if_etag: nil, as: Role::DEFAULT, correlation_id: nil)
135
+ def delete_envelope_from_disk(key, ctx:, if_etag: nil)
133
136
  _, path, = @manifest.resolve(key)
134
137
  raise UnknownKey.new(key, suggestions: @manifest.suggestions_for(key)) unless File.exist?(path)
135
138
 
@@ -138,9 +141,9 @@ module Textus
138
141
 
139
142
  File.delete(path)
140
143
  @store.audit_log.append(
141
- role: as, verb: "delete", key: key,
144
+ role: ctx.role, verb: "delete", key: key,
142
145
  etag_before: etag_before, etag_after: nil,
143
- extras: correlation_id ? { "correlation_id" => correlation_id } : nil
146
+ extras: ctx.correlation_id ? { "correlation_id" => ctx.correlation_id } : nil
144
147
  )
145
148
  end
146
149
 
@@ -164,6 +167,5 @@ module Textus
164
167
  { "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
165
168
  end
166
169
  end
167
- # rubocop:enable Metrics/ParameterLists
168
170
  end
169
171
  end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.10.3"
2
+ VERSION = "0.10.5"
3
3
  PROTOCOL = "textus/2"
4
4
  end
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.10.3
4
+ version: 0.10.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -235,6 +235,8 @@ files:
235
235
  - lib/textus/store/reader.rb
236
236
  - lib/textus/store/sentinel.rb
237
237
  - lib/textus/store/staleness.rb
238
+ - lib/textus/store/staleness/generator_check.rb
239
+ - lib/textus/store/staleness/intake_check.rb
238
240
  - lib/textus/store/validator.rb
239
241
  - lib/textus/store/writer.rb
240
242
  - lib/textus/version.rb