textus 0.54.2 → 0.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +8 -1
- data/SPEC.md +27 -0
- data/docs/architecture/README.md +20 -8
- data/docs/reference/conventions.md +1 -1
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +23 -21
- data/lib/textus/action/audit.rb +24 -61
- data/lib/textus/action/base.rb +9 -9
- data/lib/textus/action/blame.rb +18 -36
- data/lib/textus/action/boot.rb +2 -4
- data/lib/textus/action/data_mv.rb +20 -31
- data/lib/textus/action/deps.rb +3 -18
- data/lib/textus/action/doctor.rb +2 -9
- data/lib/textus/action/drain.rb +11 -19
- data/lib/textus/action/enqueue.rb +14 -30
- data/lib/textus/action/get.rb +12 -56
- data/lib/textus/action/ingest.rb +74 -78
- data/lib/textus/action/jobs.rb +6 -15
- data/lib/textus/action/key_delete.rb +6 -16
- data/lib/textus/action/key_delete_prefix.rb +8 -17
- data/lib/textus/action/key_mv.rb +54 -61
- data/lib/textus/action/key_mv_prefix.rb +13 -22
- data/lib/textus/action/list.rb +7 -21
- data/lib/textus/action/propose.rb +16 -26
- data/lib/textus/action/published.rb +3 -5
- data/lib/textus/action/pulse.rb +19 -26
- data/lib/textus/action/put.rb +15 -29
- data/lib/textus/action/rdeps.rb +3 -18
- data/lib/textus/action/reject.rb +12 -21
- data/lib/textus/action/rule_explain.rb +12 -22
- data/lib/textus/action/rule_lint.rb +10 -16
- data/lib/textus/action/rule_list.rb +5 -9
- data/lib/textus/action/schema_envelope.rb +3 -10
- data/lib/textus/action/uid.rb +3 -17
- data/lib/textus/action/where.rb +3 -18
- data/lib/textus/boot.rb +7 -15
- data/lib/textus/contract/arg.rb +10 -0
- data/lib/textus/contract/dsl.rb +88 -0
- data/lib/textus/contract/spec.rb +25 -0
- data/lib/textus/contract.rb +0 -162
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +2 -2
- data/lib/textus/doctor/check/schemas.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +4 -4
- data/lib/textus/doctor/check/templates.rb +1 -1
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +4 -7
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +6 -0
- data/lib/textus/format/base.rb +0 -4
- data/lib/textus/format/json.rb +5 -6
- data/lib/textus/format/markdown.rb +5 -6
- data/lib/textus/format/shared.rb +17 -0
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -6
- data/lib/textus/format.rb +6 -0
- data/lib/textus/gate/auth.rb +2 -17
- data/lib/textus/gate/binder.rb +50 -0
- data/lib/textus/gate.rb +64 -88
- data/lib/textus/init.rb +2 -4
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +3 -3
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
- data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
- data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
- data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
- data/lib/textus/manifest/schema/semantics.rb +11 -216
- data/lib/textus/meta.rb +54 -0
- data/lib/textus/{ports → port}/audit_log.rb +44 -4
- data/lib/textus/{ports → port}/build_lock.rb +2 -2
- data/lib/textus/{ports → port}/clock.rb +1 -1
- data/lib/textus/{ports → port}/publisher.rb +5 -5
- data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
- data/lib/textus/port/store.rb +93 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -1
- data/lib/textus/schema/tools.rb +11 -7
- data/lib/textus/store/compositor.rb +34 -0
- data/lib/textus/store/container.rb +43 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/envelope/reader.rb +43 -0
- data/lib/textus/store/envelope/writer.rb +195 -0
- data/lib/textus/store/geometry.rb +81 -0
- data/lib/textus/store/index/builder.rb +74 -0
- data/lib/textus/store/index/lookup.rb +60 -0
- data/lib/textus/store/jobs/base.rb +13 -0
- data/lib/textus/store/jobs/index.rb +15 -0
- data/lib/textus/store/jobs/materialize.rb +15 -0
- data/lib/textus/store/jobs/plan.rb +11 -0
- data/lib/textus/store/jobs/planner.rb +104 -0
- data/lib/textus/store/jobs/queue.rb +154 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention.rb +50 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/session.rb +37 -0
- data/lib/textus/store.rb +21 -13
- data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
- data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
- data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
- data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
- data/lib/textus/{surfaces → surface}/cli.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- data/lib/textus/surface/projector.rb +27 -0
- data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
- data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
- data/lib/textus/value/call.rb +30 -0
- data/lib/textus/value/command.rb +16 -0
- data/lib/textus/value/envelope.rb +89 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/result.rb +26 -0
- data/lib/textus/value/role.rb +38 -0
- data/lib/textus/value/types.rb +13 -0
- data/lib/textus/{uid.rb → value/uid.rb} +9 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +4 -18
- data/lib/textus.rb +9 -10
- metadata +100 -63
- data/lib/textus/action/write_verb.rb +0 -44
- data/lib/textus/call.rb +0 -28
- data/lib/textus/command.rb +0 -41
- data/lib/textus/container.rb +0 -26
- data/lib/textus/contract/around.rb +0 -29
- data/lib/textus/contract/binder.rb +0 -88
- data/lib/textus/contract/resources/build_lock.rb +0 -17
- data/lib/textus/contract/resources/cursor.rb +0 -26
- data/lib/textus/contract/sources.rb +0 -39
- data/lib/textus/contract/view.rb +0 -15
- data/lib/textus/cursor_store.rb +0 -24
- data/lib/textus/envelope/reader.rb +0 -46
- data/lib/textus/envelope/writer.rb +0 -209
- data/lib/textus/envelope.rb +0 -79
- data/lib/textus/etag.rb +0 -36
- data/lib/textus/jobs/base.rb +0 -23
- data/lib/textus/jobs/materialize.rb +0 -20
- data/lib/textus/jobs/plan.rb +0 -9
- data/lib/textus/jobs/planner.rb +0 -101
- data/lib/textus/jobs/retention.rb +0 -48
- data/lib/textus/jobs/sweep.rb +0 -27
- data/lib/textus/jobs/worker.rb +0 -67
- data/lib/textus/layout.rb +0 -91
- data/lib/textus/ports/job_store/job.rb +0 -65
- data/lib/textus/ports/job_store.rb +0 -123
- data/lib/textus/ports/raw_index.rb +0 -61
- data/lib/textus/role.rb +0 -36
- data/lib/textus/session.rb +0 -35
- data/lib/textus/types.rb +0 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27ac7fc2922b98c97c7873939781a13330e0765b7f0521449aa4871c414fc201
|
|
4
|
+
data.tar.gz: 055ed0684590d9ca8cf5e2ad49fc17ee05c70b8cbaa5f8b589b9bb9a3de99f0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 437568a688e1ff1b6f9786d1965633e9dfa69be5b6f7450ffeeaecb3c0be5a6729e94851f2066a26e2d313f8d23335d2ff12b83ad5e73718b27e784887c9ceb4
|
|
7
|
+
data.tar.gz: a8f4167af7c3ce315301996d3d3fedc13139f8eae4804b445ec4ed930da0d284ec96cab3467af1169ce8f6a64288d6f1d805bbfad60c0acb949f62e03ce1322d
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,43 @@ 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.55.1 — 2026-06-22 — CI fix: converge_now purges done jobs
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **publish_tree prune spec** — `converge_now` now purges "done" jobs before reseeding. The SQLite-backed job queue (`INSERT OR IGNORE`) was silently skipping re-enqueues for already-done jobs, so the second materialize never ran and the source-removed file was never pruned from the published tree.
|
|
17
|
+
|
|
18
|
+
## 0.55.0 — 2026-06-22 — Architecture Deepening Phase 2
|
|
19
|
+
|
|
20
|
+
**ADR-0119**: dry-monad actions, container split, geometry authority.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **dry-monad Success/Failure actions** (PR #234): all 30 actions now return `Success(result)` or `Failure(code:, message:)` via `Dry::Monads`. `Value::Result.unwrap` converts Failure → `ActionError`. `be_success`/`be_failure` RSpec matchers replace `spec/unit/` tests.
|
|
25
|
+
- **`Container::Infrastructure` / `Container::Coordination`** (PR #234): single `LazyContainer` replaced by two `Data.define` records — Infrastructure owns manifest, file_store, geometry; Coordination owns gate, compositor, workflows, audit_log. Wired at boot via `wire_gate!`.
|
|
26
|
+
- **`Geometry` as sole path authority** (PR #234): `root`, `schemas_dir`, `hooks_dir`, `store_db_path` delegated to `Geometry`. Removed `root` from Infrastructure. High-value `File.join` callers converted (data_mv, ingest, base, loader, etag, schema/tools, 6 doctor checks).
|
|
27
|
+
- **`bin/db` helper**: sqlite3 wrapper for `.textus/.state/store.db` — `bin/db "SELECT * FROM jobs"` or just `bin/db` for interactive shell.
|
|
28
|
+
- **Centralized SQLite index** (PR #228, #233): `Store::Index::Builder` manages entries FTS index in `store.db`.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- **Gate reads `Action::VERBS` directly** (PR #234): `Gate::VERB_ACTIONS` deleted. Gate dispatches via `Action::VERBS[cmd.verb]`.
|
|
33
|
+
- **Composite actions** (PR #232): `Accept`, `Propose`, `Reject` separated as composite verbs with declarative `chain` steps, never instantiating sibling action classes directly.
|
|
34
|
+
- **`knowledge.decisions` → keyless nested mode**: decision files no longer enumerate in `list`; accessible via `get` with full key.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- **CLI `--args` JSON parsing** (PR #233): `Runner.coerce` now `JSON.parse(raw)` for `Hash`-typed args — fixes `textus enqueue --args='{"key":"..."}'` which previously passed the raw JSON string to `Queue::Job`.
|
|
39
|
+
- **adr-log workflow convention drift** (PR #233): `Get.new(key:).call(...)` → `Get.call(key: k, ...)` — the old pattern raised silently since actions became class-method-only.
|
|
40
|
+
- **RuboCop `Lint/NoReturnInBeginEndBlocks`**: enqueue action restructured with method-level `rescue`.
|
|
41
|
+
|
|
42
|
+
### Removed
|
|
43
|
+
|
|
44
|
+
- `Gate::VERB_ACTIONS` constant (replaced by `Action::VERBS`)
|
|
45
|
+
- `LazyContainer` (replaced by `Container::Infrastructure`/`Coordination`)
|
|
46
|
+
- `spec/unit/` directory + `spec/support/spec_layout.rb` (378 examples removed)
|
|
47
|
+
- Orphaned `.textus/.state/jobs.sqlite3` database file
|
|
48
|
+
|
|
12
49
|
## 0.54.2 — 2026-06-18 — ingest dedup, .run → .state rename
|
|
13
50
|
|
|
14
51
|
### Added
|
data/README.md
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
<!-- Generated from .
|
|
1
|
+
<!-- Generated from knowledge.readme.* fragments by the artifacts.docs.readme workflow. Edit the fragments under .textus/data/knowledge/readme/ and run `textus drain`. -->
|
|
2
|
+
<!-- This is a fragment of the README. Edit here; the README is composed from fragments by a workflow. -->
|
|
2
3
|
<p align="center">
|
|
3
4
|
<picture>
|
|
4
5
|
<source media="(prefers-color-scheme: dark)" srcset="assets/branding/wordmark-dark.png">
|
|
@@ -18,6 +19,8 @@
|
|
|
18
19
|
|
|
19
20
|
*textus* is Latin for "the fabric a text is woven from" — same root as *context*, from *con-texere*, "to weave together."
|
|
20
21
|
|
|
22
|
+
|
|
23
|
+
<!-- This is a fragment of the README. Edit here; the README is composed from fragments by a workflow. -->
|
|
21
24
|
## The idea
|
|
22
25
|
|
|
23
26
|
Three actors write to your repo today:
|
|
@@ -90,6 +93,8 @@ An agent that tries to write directly into `knowledge/` gets `write_forbidden`.
|
|
|
90
93
|
|
|
91
94
|
That's the load-bearing claim: **coordination is a protocol invariant, not a library convenience.**
|
|
92
95
|
|
|
96
|
+
|
|
97
|
+
<!-- This is a fragment of the README. Edit here; the README is composed from fragments by a workflow. -->
|
|
93
98
|
## See it in four commands
|
|
94
99
|
|
|
95
100
|
```sh
|
|
@@ -316,3 +321,5 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
|
|
|
316
321
|
## License
|
|
317
322
|
|
|
318
323
|
[MIT](LICENSE)
|
|
324
|
+
|
|
325
|
+
|
data/SPEC.md
CHANGED
|
@@ -438,6 +438,22 @@ textus ingest url agentskills-io-brainstorming \
|
|
|
438
438
|
|
|
439
439
|
A `get` on a raw entry is a pure read — it returns the entry as stored and never re-fetches (ADR 0089).
|
|
440
440
|
|
|
441
|
+
**Raw entry shape.** Every raw-lane entry has a set of required fields
|
|
442
|
+
enforced by the format layer on write:
|
|
443
|
+
|
|
444
|
+
| Field | Required | Description |
|
|
445
|
+
|-------|----------|-------------|
|
|
446
|
+
| `ingested_at` | yes | ISO-8601 timestamp of ingestion |
|
|
447
|
+
| `content_hash` | yes | `sha256:<hex>` of the source content |
|
|
448
|
+
| `source.kind` | yes | One of `url`, `file`, `asset` |
|
|
449
|
+
| `source.url` | conditional | Required when `kind=url` |
|
|
450
|
+
| `source.label` | no | Human-readable label |
|
|
451
|
+
| `body` | no | Present only for `kind=file` |
|
|
452
|
+
| `asset` | no | Present only for `kind=asset` |
|
|
453
|
+
|
|
454
|
+
Tombstone entries (carrying `superseded_by:`) are exempt from shape
|
|
455
|
+
validation — they are stripped-down records pointing to the live entry.
|
|
456
|
+
|
|
441
457
|
### 5.5 Pending / accept workflow
|
|
442
458
|
|
|
443
459
|
Proposal entries are full patches authored into the `proposals` queue lane (writable by `propose`-holders: `agent` and `human` by default) — `proposals` in the default scaffold (Setup-1) — typically by agents. The entry's frontmatter describes the patch it proposes against another lane:
|
|
@@ -646,6 +662,13 @@ The frontmatter `name:` field, when present, must match the file's basename (wit
|
|
|
646
662
|
- Existing files without a uid continue to work. The envelope shows `"uid": null` until a put mints one.
|
|
647
663
|
- `text` entries have no metadata channel and therefore no uid; their envelope always shows `"uid": null`.
|
|
648
664
|
|
|
665
|
+
**`sources:` (Source references).** Entries MAY carry a `sources` array in
|
|
666
|
+
their frontmatter to declare external provenance. Each element is a raw-lane
|
|
667
|
+
key string (starting with `raw.`). The array is preserved on write —
|
|
668
|
+
existing sources carry forward if no new `sources` are provided. The
|
|
669
|
+
envelope returns `sources` as a top-level array when non-empty; omitted
|
|
670
|
+
when absent.
|
|
671
|
+
|
|
649
672
|
Entries in a `produced` lane SHOULD additionally carry the `generated:` block defined in §5.2. Implementations MUST treat unknown frontmatter fields as warnings, not errors, so build tooling can extend the metadata without breaking conformance.
|
|
650
673
|
|
|
651
674
|
## 8. Envelope (the wire format)
|
|
@@ -665,6 +688,9 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
665
688
|
"etag": "sha256:8f3c…",
|
|
666
689
|
"schema_ref": "person",
|
|
667
690
|
"uid": "a1b2c3d4e5f60718",
|
|
691
|
+
"sources": [
|
|
692
|
+
"raw.2026.06.20.url-mcp-spec"
|
|
693
|
+
],
|
|
668
694
|
"stale": false,
|
|
669
695
|
"stale_reason": null,
|
|
670
696
|
"fetching": false
|
|
@@ -682,6 +708,7 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
|
|
|
682
708
|
- `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
|
|
683
709
|
- `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
|
|
684
710
|
- `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
|
|
711
|
+
- `sources` is an array of raw-lane key strings. Present only when non-empty. Each string starts with `raw.`.
|
|
685
712
|
- `stale` is `true` when the entry's `source.ttl` has elapsed and the entry has not yet been re-materialised; `false` otherwise. Only populated for produced entries with a declared `ttl`; always `false` for other entries.
|
|
686
713
|
- `stale_reason` is a short human-readable string describing why the entry is stale (e.g. `"ttl_exceeded"`, `"never_fetched"`), or `null` when `stale` is `false`.
|
|
687
714
|
- `fetching` is `true` when a background re-pull is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
|
data/docs/architecture/README.md
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
```mermaid
|
|
7
7
|
flowchart TD
|
|
8
8
|
surfaces["Surfaces — CLI · MCP · RoleScope"]
|
|
9
|
-
contract["Contract — per-verb DSL (
|
|
9
|
+
contract["Contract — per-verb DSL (Arg, Spec, DSL)"]
|
|
10
|
+
dispatch["Dispatch — Binder · Around · CommandBuilder · Dispatcher"]
|
|
10
11
|
gate["Gate — routing (VERB_COMMAND) · Auth"]
|
|
11
12
|
action["Actions — per-verb use cases (action/)"]
|
|
12
13
|
jobs["Jobs — Worker · Planner · Materialize · Retention · Sweep"]
|
|
@@ -15,8 +16,10 @@ flowchart TD
|
|
|
15
16
|
manifest["Manifest — declarative config, no IO (policy/, schema/, entry/)"]
|
|
16
17
|
core["Core — pure value types (Freshness, Retention, Duration, Sentinel)"]
|
|
17
18
|
ports["Ports — IO adapters (FileStore, AuditLog, JobStore, Publisher…)"]
|
|
18
|
-
surfaces -->
|
|
19
|
-
|
|
19
|
+
surfaces --> dispatch
|
|
20
|
+
surfaces -.->|declare| contract
|
|
21
|
+
contract --> dispatch
|
|
22
|
+
dispatch --> gate
|
|
20
23
|
gate --> action
|
|
21
24
|
action --> jobs
|
|
22
25
|
action --> produce
|
|
@@ -27,7 +30,7 @@ flowchart TD
|
|
|
27
30
|
jobs --> produce
|
|
28
31
|
```
|
|
29
32
|
|
|
30
|
-
*Dependency rule: inward only.* Surfaces →
|
|
33
|
+
*Dependency rule: inward only.* Surfaces → Dispatch → Gate → Actions → inner layers. Actions never reference surfaces.
|
|
31
34
|
|
|
32
35
|
### What lives in each layer
|
|
33
36
|
|
|
@@ -35,18 +38,27 @@ flowchart TD
|
|
|
35
38
|
|
|
36
39
|
```
|
|
37
40
|
surfaces/cli/ CLI command generation from contracts
|
|
41
|
+
sources.rb CLI-only input acquisition (stdin parsing, file sourcing, coercion)
|
|
38
42
|
surfaces/mcp/ MCP server — stdio JSON-RPC 2.0, tools derived from contracts
|
|
39
43
|
surfaces/role_scope.rb
|
|
40
44
|
(Store#as(role)) — holds (container, role, dry_run, correlation_id);
|
|
41
45
|
all verb methods injected via define_method in textus.rb
|
|
42
46
|
```
|
|
43
47
|
|
|
44
|
-
**Contract**
|
|
48
|
+
**Contract + Dispatch**
|
|
45
49
|
|
|
46
50
|
```
|
|
47
|
-
contract/ Per-verb DSL — verb, summary, surfaces, arg, view.
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
contract/ Per-verb DSL — verb, summary, surfaces, arg, view, around.
|
|
52
|
+
Pure declaration: Spec and DSL only.
|
|
53
|
+
|
|
54
|
+
dispatch/ Runtime machinery consumed by every surface.
|
|
55
|
+
binder.rb Binder — validates required args, resolves session/literal defaults.
|
|
56
|
+
around.rb Around — registry of stateful wrappers (cursor, build_lock).
|
|
57
|
+
view.rb View.render(spec, surface, result, inputs) — output shaping.
|
|
58
|
+
command_builder.rb
|
|
59
|
+
CommandBuilder — command construction + role injection.
|
|
60
|
+
dispatcher.rb Dispatcher — unified pipeline: Around → Binder → Command → Gate.
|
|
61
|
+
resources/ Around resource implementations (Cursor, BuildLock).
|
|
50
62
|
```
|
|
51
63
|
|
|
52
64
|
**Gate + Auth**
|
|
@@ -126,7 +126,7 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
|
|
|
126
126
|
The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](../architecture/decisions/0018-manifest-carving.md), [ADR 0017](../architecture/decisions/0017-envelope-io-split.md), [ADR 0022](../architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](../architecture/decisions/0023-uniform-use-case-shape.md).
|
|
127
127
|
|
|
128
128
|
- **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(lane)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
|
|
129
|
-
- **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/
|
|
129
|
+
- **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/action/` with `def initialize(...)` (args from contract) and a `#call(container:, call:)` method; verbs are looked up in the static `Textus::Action::VERBS` table. `Container` is a `Dry::Struct` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `workflows`, `gate`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). All dispatch routes through `Gate#dispatch(spec:, inputs:, role:, correlation_id:, session:)` — the single seam that binds inputs via `Gate::Binder`, checks auth, and invokes the action. `store.as(role)` returns a `RoleScope` that forwards verbs to `Gate::dispatch`.
|
|
130
130
|
- **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
|
|
131
131
|
|
|
132
132
|
The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/4`) are unchanged.
|
data/exe/textus
CHANGED
data/lib/textus/action/accept.rb
CHANGED
|
@@ -2,42 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Action
|
|
5
|
-
class Accept <
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
5
|
+
class Accept < Base
|
|
8
6
|
verb :accept
|
|
9
7
|
summary "apply a queued proposal to its target zone; requires the author capability"
|
|
10
8
|
surfaces :cli, :mcp
|
|
11
9
|
cli "accept"
|
|
12
10
|
arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
|
|
13
11
|
|
|
14
|
-
def
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
def self.call(container:, call:, pending_key:)
|
|
13
|
+
env = container.compositor.read(pending_key)
|
|
14
|
+
parsed = proposal_from(env, key: pending_key)
|
|
15
|
+
return parsed if parsed.is_a?(Dry::Monads::Result::Failure)
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
proposal = env.meta["proposal"] or raise Textus::ProposalError.new("entry has no proposal block: #{@pending_key}")
|
|
22
|
-
target = proposal["target_key"] or raise Textus::ProposalError.new("proposal missing target_key")
|
|
23
|
-
action = proposal["action"] || "put"
|
|
17
|
+
target = parsed[:target_key]
|
|
18
|
+
action = parsed[:proposal]["action"] || "put"
|
|
24
19
|
|
|
25
20
|
case action
|
|
26
21
|
when "put"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
mentry = container.manifest.resolver.resolve(target).entry
|
|
23
|
+
container.compositor.write(
|
|
24
|
+
target,
|
|
25
|
+
mentry: mentry,
|
|
26
|
+
payload: Textus::Store::Envelope::Writer::Payload.new(
|
|
27
|
+
meta: env.meta["_meta"] || {},
|
|
28
|
+
body: env.body,
|
|
29
|
+
content: nil,
|
|
30
|
+
),
|
|
31
|
+
call: call,
|
|
32
|
+
)
|
|
32
33
|
when "delete"
|
|
33
|
-
|
|
34
|
+
container.compositor.delete(target, call: call)
|
|
34
35
|
else
|
|
35
|
-
|
|
36
|
+
return Failure(code: :proposal_error, message: "unknown action: #{action}")
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
container.compositor.delete(pending_key, call: call)
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
Success("protocol" => Textus::PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action,
|
|
42
|
+
"cascade_key" => target)
|
|
41
43
|
end
|
|
42
44
|
end
|
|
43
45
|
end
|
data/lib/textus/action/audit.rb
CHANGED
|
@@ -6,8 +6,6 @@ require "time"
|
|
|
6
6
|
module Textus
|
|
7
7
|
module Action
|
|
8
8
|
class Audit < Base
|
|
9
|
-
extend Textus::Contract::DSL
|
|
10
|
-
|
|
11
9
|
verb :audit
|
|
12
10
|
summary "Query the audit log with optional filters."
|
|
13
11
|
surfaces :cli
|
|
@@ -24,42 +22,26 @@ module Textus
|
|
|
24
22
|
arg :limit, Integer, required: false, description: "maximum number of rows to return"
|
|
25
23
|
view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
|
|
26
24
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def args
|
|
33
|
-
@query.to_h.compact
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def call(container:, **)
|
|
37
|
-
@manifest = container.manifest
|
|
38
|
-
@root = container.root
|
|
39
|
-
@log_path = Textus::Layout.audit_log(container.root)
|
|
40
|
-
@audit_log = container.audit_log
|
|
41
|
-
|
|
42
|
-
query = @query
|
|
43
|
-
check_cursor_expiry!(query.seq_since)
|
|
25
|
+
def self.call(container:, key: nil, lane: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil, **) # rubocop:disable Metrics/ParameterLists
|
|
26
|
+
audit_log = container.audit_log
|
|
27
|
+
manifest = container.manifest
|
|
44
28
|
|
|
45
|
-
|
|
46
|
-
return
|
|
29
|
+
cursor_check = check_cursor_expiry(seq_since, audit_log)
|
|
30
|
+
return cursor_check if cursor_check.is_a?(Dry::Monads::Result::Failure)
|
|
47
31
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
end
|
|
59
|
-
break if limit_reached?(rows, query)
|
|
60
|
-
end
|
|
32
|
+
Success(audit_log.scan(
|
|
33
|
+
seq_since: seq_since,
|
|
34
|
+
key: key,
|
|
35
|
+
role: role,
|
|
36
|
+
verb: verb,
|
|
37
|
+
correlation_id: correlation_id,
|
|
38
|
+
limit: limit,
|
|
39
|
+
).select do |row|
|
|
40
|
+
next false if lane && !key_in_lane?(row["key"], lane, manifest)
|
|
41
|
+
next false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
|
|
61
42
|
|
|
62
|
-
|
|
43
|
+
true
|
|
44
|
+
end)
|
|
63
45
|
end
|
|
64
46
|
|
|
65
47
|
def self.parse_since(str, now: Time.now.utc)
|
|
@@ -91,37 +73,18 @@ module Textus
|
|
|
91
73
|
end
|
|
92
74
|
end
|
|
93
75
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
|
|
97
|
-
|
|
98
|
-
def check_cursor_expiry!(seq_since)
|
|
76
|
+
def self.check_cursor_expiry(seq_since, audit_log)
|
|
99
77
|
return unless seq_since
|
|
100
78
|
|
|
101
|
-
|
|
102
|
-
min
|
|
103
|
-
raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def all_log_files
|
|
107
|
-
rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
|
|
108
|
-
.reject { |path| path.end_with?(".meta.json") }
|
|
109
|
-
.sort_by { |path| -path.scan(/\d+$/).first.to_i }
|
|
110
|
-
active = File.exist?(@log_path) ? [@log_path] : []
|
|
111
|
-
rotated + active
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def parse_row(line)
|
|
115
|
-
return nil if line.empty?
|
|
116
|
-
return nil unless line.start_with?("{")
|
|
79
|
+
min = audit_log.min_available_seq
|
|
80
|
+
return unless min && seq_since < min - 1
|
|
117
81
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
nil
|
|
82
|
+
Failure(code: :cursor_expired, message: "requested seq #{seq_since} is below minimum available #{min}",
|
|
83
|
+
details: { requested: seq_since, min_available: min })
|
|
121
84
|
end
|
|
122
85
|
|
|
123
|
-
def key_in_lane?(key, lane)
|
|
124
|
-
mentry =
|
|
86
|
+
def self.key_in_lane?(key, lane, manifest)
|
|
87
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
125
88
|
mentry && mentry.lane == lane
|
|
126
89
|
rescue Textus::Error
|
|
127
90
|
false
|
data/lib/textus/action/base.rb
CHANGED
|
@@ -20,22 +20,22 @@ module Textus
|
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
class Base
|
|
23
|
+
extend Contract::DSL
|
|
24
|
+
extend Dry::Monads[:result]
|
|
25
|
+
|
|
23
26
|
def self.inherited(subclass)
|
|
24
27
|
super
|
|
25
28
|
Textus::Action.register(subclass) if subclass.name
|
|
26
29
|
end
|
|
27
30
|
|
|
28
|
-
def call(**)
|
|
29
|
-
raise NotImplementedError.new("#{
|
|
31
|
+
def self.call(**)
|
|
32
|
+
raise NotImplementedError.new("#{name}.call")
|
|
30
33
|
end
|
|
31
34
|
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
val = instance_variable_get(:"@#{name}")
|
|
37
|
-
h[name] = val unless val.nil?
|
|
38
|
-
end
|
|
35
|
+
def self.proposal_from(env, key:)
|
|
36
|
+
proposal = env.meta&.dig("proposal") or return Failure(code: :proposal_error, message: "entry has no proposal block: #{key}")
|
|
37
|
+
target = proposal["target_key"] or return Failure(code: :proposal_error, message: "proposal missing target_key")
|
|
38
|
+
{ proposal:, target_key: target }
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
41
|
end
|
data/lib/textus/action/blame.rb
CHANGED
|
@@ -5,8 +5,6 @@ require "open3"
|
|
|
5
5
|
module Textus
|
|
6
6
|
module Action
|
|
7
7
|
class Blame < Base
|
|
8
|
-
extend Textus::Contract::DSL
|
|
9
|
-
|
|
10
8
|
verb :blame
|
|
11
9
|
summary "Annotate audit rows for a key with the git commit that introduced each file state."
|
|
12
10
|
surfaces :cli
|
|
@@ -15,59 +13,43 @@ module Textus
|
|
|
15
13
|
arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
|
|
16
14
|
view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
|
|
17
15
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@limit = limit
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def call(container:, **)
|
|
25
|
-
@container = container
|
|
26
|
-
@manifest = container.manifest
|
|
27
|
-
@root = container.root
|
|
16
|
+
def self.call(container:, key:, limit: nil, **)
|
|
17
|
+
manifest = container.manifest
|
|
18
|
+
root = container.root
|
|
28
19
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
audit_result = Textus::Action::Audit.call(container: container, key: key, limit: limit)
|
|
21
|
+
audit_rows = Value::Result.unwrap(audit_result)
|
|
22
|
+
path = resolve_path(key, manifest: manifest)
|
|
23
|
+
return Success(audit_rows.map { |row| row.merge("git" => nil) }) unless git_tracked?(path, root: root)
|
|
32
24
|
|
|
33
|
-
audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"])) }
|
|
25
|
+
Success(audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"], root: root)) })
|
|
34
26
|
end
|
|
35
27
|
|
|
36
|
-
def self.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
|
|
40
|
-
mapped = positional.zip(args).to_h
|
|
41
|
-
super(**mapped.merge(kwargs))
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
private
|
|
45
|
-
|
|
46
|
-
def resolve_path(key)
|
|
47
|
-
res = @manifest.resolver.resolve(key)
|
|
28
|
+
def self.resolve_path(key, manifest:)
|
|
29
|
+
res = manifest.resolver.resolve(key)
|
|
48
30
|
mentry = res.entry
|
|
49
31
|
path = res.path
|
|
50
|
-
path || Textus::Key::Path.resolve(
|
|
32
|
+
path || Textus::Key::Path.resolve(manifest.data, mentry)
|
|
51
33
|
rescue Textus::Error
|
|
52
34
|
nil
|
|
53
35
|
end
|
|
54
36
|
|
|
55
|
-
def git_tracked?(path)
|
|
37
|
+
def self.git_tracked?(path, root:)
|
|
56
38
|
return false if path.nil?
|
|
57
39
|
return false unless File.exist?(path)
|
|
58
|
-
return false unless git_repo?
|
|
40
|
+
return false unless git_repo?(root)
|
|
59
41
|
|
|
60
42
|
_out, _err, status = Open3.capture3(
|
|
61
43
|
"git", "ls-files", "--error-unmatch", path,
|
|
62
|
-
chdir:
|
|
44
|
+
chdir: root
|
|
63
45
|
)
|
|
64
46
|
status.success?
|
|
65
47
|
rescue Errno::ENOENT
|
|
66
48
|
false
|
|
67
49
|
end
|
|
68
50
|
|
|
69
|
-
def git_repo?
|
|
70
|
-
dir =
|
|
51
|
+
def self.git_repo?(root)
|
|
52
|
+
dir = root
|
|
71
53
|
loop do
|
|
72
54
|
return true if File.directory?(File.join(dir, ".git"))
|
|
73
55
|
|
|
@@ -78,11 +60,11 @@ module Textus
|
|
|
78
60
|
end
|
|
79
61
|
end
|
|
80
62
|
|
|
81
|
-
def git_commit_at(path, timestamp:)
|
|
63
|
+
def self.git_commit_at(path, timestamp:, root:)
|
|
82
64
|
args = ["git", "log", "-1"]
|
|
83
65
|
args << "--before=#{timestamp}" if timestamp
|
|
84
66
|
args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
|
|
85
|
-
out, _err, status = Open3.capture3(*args, chdir:
|
|
67
|
+
out, _err, status = Open3.capture3(*args, chdir: root)
|
|
86
68
|
return nil unless status.success?
|
|
87
69
|
|
|
88
70
|
sha, author, date, subject = out.strip.split("\t", 4)
|
data/lib/textus/action/boot.rb
CHANGED
|
@@ -3,14 +3,12 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Action
|
|
5
5
|
class Boot < Base
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
6
|
verb :boot
|
|
9
7
|
summary "Return the orientation contract: lanes, agent_quickstart, agent_protocol, and pre-computed artifacts."
|
|
10
8
|
surfaces :cli, :mcp
|
|
11
9
|
|
|
12
|
-
def call(container:, **)
|
|
13
|
-
Textus::Boot.build(container: container)
|
|
10
|
+
def self.call(container:, **)
|
|
11
|
+
Success(Textus::Boot.build(container: container))
|
|
14
12
|
end
|
|
15
13
|
end
|
|
16
14
|
end
|
|
@@ -5,8 +5,6 @@ require "yaml"
|
|
|
5
5
|
module Textus
|
|
6
6
|
module Action
|
|
7
7
|
class DataMv < Base
|
|
8
|
-
extend Textus::Contract::DSL
|
|
9
|
-
|
|
10
8
|
verb :data_mv
|
|
11
9
|
summary "Rename a data lane — manifest + files. Refuses if destination exists."
|
|
12
10
|
surfaces :cli, :mcp
|
|
@@ -19,48 +17,39 @@ module Textus
|
|
|
19
17
|
"defaults to false, so omitting it applies the move immediately"
|
|
20
18
|
view { |v, _i| v.to_h }
|
|
21
19
|
|
|
22
|
-
def
|
|
23
|
-
super()
|
|
24
|
-
@from = from
|
|
25
|
-
@to = to
|
|
26
|
-
@dry_run = dry_run
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def call(container:, **)
|
|
20
|
+
def self.call(container:, call:, from:, to:, dry_run: false, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
30
21
|
manifest = container.manifest
|
|
31
|
-
|
|
22
|
+
geom = container.geometry
|
|
32
23
|
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
return Failure(code: :usage_error, message: "from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
25
|
+
return Failure(code: :usage_error, message: "data lane '#{from}' not declared") unless manifest.data.declared_lane_kinds.key?(from)
|
|
35
26
|
|
|
36
|
-
dest_dir =
|
|
37
|
-
|
|
27
|
+
dest_dir = geom.lane_path(to)
|
|
28
|
+
return Failure(code: :usage_error, message: "destination 'data/#{to}' already exists") if File.exist?(dest_dir)
|
|
38
29
|
|
|
39
|
-
affected_keys = manifest.data.entries.select { |entry| entry.lane ==
|
|
30
|
+
affected_keys = manifest.data.entries.select { |entry| entry.lane == from }.map(&:key)
|
|
40
31
|
|
|
41
|
-
steps = [{ "op" => "rename_zone", "from" =>
|
|
32
|
+
steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
|
|
42
33
|
steps += affected_keys.map do |key|
|
|
43
|
-
{ "op" => "mv", "from" => key, "to" => "#{
|
|
34
|
+
{ "op" => "mv", "from" => key, "to" => "#{to}#{key[from.length..]}" }
|
|
44
35
|
end
|
|
45
36
|
|
|
46
|
-
plan = Textus::Jobs::Plan.new(steps: steps, warnings: [])
|
|
47
|
-
return plan if
|
|
37
|
+
plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: [])
|
|
38
|
+
return Success(plan) if dry_run
|
|
48
39
|
|
|
49
|
-
rewrite_manifest!(
|
|
50
|
-
FileUtils.mv(
|
|
51
|
-
plan
|
|
40
|
+
rewrite_manifest!(geom, from:, to:)
|
|
41
|
+
FileUtils.mv(geom.lane_path(from), dest_dir)
|
|
42
|
+
Success(plan)
|
|
52
43
|
end
|
|
53
44
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def rewrite_manifest!(root)
|
|
57
|
-
path = File.join(root, "manifest.yaml")
|
|
45
|
+
def self.rewrite_manifest!(geom, from:, to:)
|
|
46
|
+
path = geom.manifest_path
|
|
58
47
|
raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
|
|
59
|
-
raw["lanes"].each { |lane| lane["name"] =
|
|
48
|
+
raw["lanes"].each { |lane| lane["name"] = to if lane["name"] == from }
|
|
60
49
|
raw["entries"].each do |entry|
|
|
61
|
-
entry["lane"] =
|
|
62
|
-
entry["key"] = entry["key"].sub(/\A#{Regexp.escape(
|
|
63
|
-
entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(
|
|
50
|
+
entry["lane"] = to if entry["lane"] == from
|
|
51
|
+
entry["key"] = entry["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
|
|
52
|
+
entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(from)}(/|\z)}, "\\1#{to}\\2")
|
|
64
53
|
end
|
|
65
54
|
File.write(path, YAML.dump(raw))
|
|
66
55
|
end
|