textus 0.20.0 → 0.20.2
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 +65 -0
- data/SPEC.md +35 -2
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/application/policy/promotion.rb +6 -11
- data/lib/textus/application/reads/validator.rb +3 -1
- data/lib/textus/application/writes/accept.rb +5 -1
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- data/lib/textus/application/writes/publish.rb +2 -2
- data/lib/textus/application/writes/reject.rb +5 -1
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/doctor/check/illegal_keys.rb +2 -3
- data/lib/textus/domain/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -0
- data/lib/textus/intro.rb +44 -20
- data/lib/textus/manifest/entry/base.rb +2 -2
- data/lib/textus/manifest/entry/parser.rb +1 -7
- data/lib/textus/manifest/resolver.rb +4 -1
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +45 -1
- data/lib/textus/manifest.rb +21 -1
- data/lib/textus/schema/tools.rb +8 -1
- data/lib/textus/version.rb +1 -1
- metadata +4 -7
- data/lib/textus/application/policy/predicates/human_accept.rb +0 -30
- data/lib/textus/application/tools/migrate_keys.rb +0 -191
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +0 -31
- data/lib/textus/cli/verb/key_normalize.rb +0 -48
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/manifest/resolution.rb +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 35d3b6540fc043048133df0874e76b36e522d844da783b69a5e8993418157ae4
|
|
4
|
+
data.tar.gz: e0293b87efb32c1edf2d6c0103dcd94289d91031a4be570f8fdd40fb681c1e79
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '039b6f44941ea52ac8d956297d9619c4cc8b2346e7d8bced24bc5671506726a44348da66791aa6d032e56dd403353d1b34e9b2fee37eaec05cd6c83d5defc715'
|
|
7
|
+
data.tar.gz: e6e5e8f97f5f9a07a03813d61f9efa2088fb8f1338af89ba1298bf307c6c72e89f1028aa5a67ffdf8f97f2151c0af1273f82ff80751c59c11aa93ef2953323a9
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,71 @@ 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.20.2 — 2026-05-27
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Promotion predicate `accept_authority_signed` now checks the role's *kind*
|
|
16
|
+
via `manifest.role_kind`, so manifests with a renamed authority role (e.g.
|
|
17
|
+
`owner` instead of `human`) pass the promotion gate. The internal class
|
|
18
|
+
`Predicates::HumanAccept` was renamed to `Predicates::AcceptAuthoritySigned`.
|
|
19
|
+
- `textus schema migrate` now writes as the manifest's declared
|
|
20
|
+
`accept_authority` role instead of the literal `"human"`, and raises a
|
|
21
|
+
clear `UsageError` (with a YAML hint) when no `accept_authority` role is
|
|
22
|
+
declared.
|
|
23
|
+
- `textus accept` / `textus reject` no longer claim "only human role can
|
|
24
|
+
accept" when the manifest declares zero `accept_authority` roles — the
|
|
25
|
+
error now says "no role with accept_authority kind is declared in this
|
|
26
|
+
manifest; accept/reject is disabled".
|
|
27
|
+
- `textus build` now resolves the build role from the manifest's declared
|
|
28
|
+
`generator` kind instead of hardcoding `"builder"`, so renamed generator
|
|
29
|
+
roles work correctly.
|
|
30
|
+
- Manifest validator's "exactly one accept_authority" error message now
|
|
31
|
+
matches what the schema actually enforces.
|
|
32
|
+
|
|
33
|
+
### Removed
|
|
34
|
+
- Legacy `human_accept` promotion-predicate alias (string and symbol forms).
|
|
35
|
+
Manifests using `rules[].promotion.requires: [human_accept]` must change
|
|
36
|
+
to `[accept_authority_signed]`. The error on the old form is actionable:
|
|
37
|
+
`unknown promotion predicate: 'human_accept' (known: schema_valid,
|
|
38
|
+
accept_authority_signed)`.
|
|
39
|
+
- `textus key normalize` verb and the underlying
|
|
40
|
+
`Textus::Application::Tools::MigrateKeys` module. Files dropped into nested
|
|
41
|
+
zones with illegal basenames are still reported by `textus doctor` with a
|
|
42
|
+
`key.illegal` finding; fix them by hand. The `--upgrade-manifest` flag and
|
|
43
|
+
its `Textus::Application::Tools::MigrateManifestToKinds` module (one-shot
|
|
44
|
+
0.19→0.20 manifest upgrader) are removed for the same reason — dead weight.
|
|
45
|
+
- The `migrate-keys` audit-log payload string is no longer emitted (no writer
|
|
46
|
+
produces it).
|
|
47
|
+
|
|
48
|
+
### Internal
|
|
49
|
+
- Final cleanup of role-name leaks identified by the 0.20.2 architecture
|
|
50
|
+
audit (follow-on to 0.20.1 role-kinds refactor).
|
|
51
|
+
|
|
52
|
+
## 0.20.1 — 2026-05-27
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- Optional `roles:` block in `manifest.yaml` lets users rename roles without
|
|
56
|
+
breaking engine semantics. Each declared role maps to one of four engine
|
|
57
|
+
kinds: `accept_authority`, `generator`, `proposer`, `runner`. (#72)
|
|
58
|
+
- `Manifest#role_kind`, `Manifest#roles_with_kind`, `Manifest#zone_kinds`
|
|
59
|
+
accessors for engine integrations.
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
- `accept` / `reject` now gate on `accept_authority` kind, not the literal
|
|
63
|
+
`"human"` role. Error messages cite the configured role name.
|
|
64
|
+
- `validator` last-writer trust check uses `accept_authority` kind.
|
|
65
|
+
- Entry `in_generator_zone?` / `in_proposal_zone?` query `zone_kinds`.
|
|
66
|
+
- `Intro` derives `write_flows` and `agent_protocol.role_resolution.roles`
|
|
67
|
+
from the manifest's role mapping.
|
|
68
|
+
- Promote DSL predicate `:human_accept` renamed to `:accept_authority_signed`;
|
|
69
|
+
the old symbol still works as an alias.
|
|
70
|
+
- Schema rejects zone writers that reference an undeclared role when `roles:`
|
|
71
|
+
is declared.
|
|
72
|
+
|
|
73
|
+
### Compatibility
|
|
74
|
+
- No wire protocol change (`textus/3`).
|
|
75
|
+
- Existing manifests without a `roles:` block behave identically to 0.20.0.
|
|
76
|
+
|
|
12
77
|
## 0.20.0 — architecture redesign (2026-05-27)
|
|
13
78
|
|
|
14
79
|
**BREAKING (pre-1.0):** Public top-level utility modules removed,
|
data/SPEC.md
CHANGED
|
@@ -229,6 +229,40 @@ Unknown role values are rejected with `invalid_role`.
|
|
|
229
229
|
|
|
230
230
|
Every successful write records the resolved role and a wall-clock timestamp in `.textus/audit.log`, so reviewers can later distinguish a human edit from an agent edit even though both live in the same file.
|
|
231
231
|
|
|
232
|
+
#### 5.1.1 Role kinds (engine semantics)
|
|
233
|
+
|
|
234
|
+
Internally the engine recognizes four **role kinds** — abstract capability
|
|
235
|
+
markers — rather than the four default role names. A manifest may declare a
|
|
236
|
+
`roles:` block to map any role name to a kind:
|
|
237
|
+
|
|
238
|
+
```yaml
|
|
239
|
+
roles:
|
|
240
|
+
- { name: owner, kind: accept_authority }
|
|
241
|
+
- { name: compiler, kind: generator }
|
|
242
|
+
- { name: proposer, kind: proposer }
|
|
243
|
+
- { name: fetcher, kind: runner }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Kind allow-list: `accept_authority`, `generator`, `proposer`, `runner`.
|
|
247
|
+
At most one role may have `accept_authority`. When `roles:` is declared,
|
|
248
|
+
every entry in `zones[*].write_policy` must be a declared role name.
|
|
249
|
+
|
|
250
|
+
When the `roles:` block is omitted, the default mapping applies:
|
|
251
|
+
|
|
252
|
+
| Default name | Kind |
|
|
253
|
+
|---|---|
|
|
254
|
+
| `human` | `accept_authority` |
|
|
255
|
+
| `agent` | `proposer` |
|
|
256
|
+
| `builder` | `generator` |
|
|
257
|
+
| `runner` | `runner` |
|
|
258
|
+
|
|
259
|
+
This means existing manifests continue to work byte-for-byte. Wire protocol
|
|
260
|
+
`textus/3` is unchanged — kinds are an internal-semantics concept and never
|
|
261
|
+
appear on the wire.
|
|
262
|
+
|
|
263
|
+
The promotion DSL predicate `:human_accept` is now `:accept_authority_signed`;
|
|
264
|
+
the old symbol works as an alias for backwards compatibility.
|
|
265
|
+
|
|
232
266
|
### 5.2 Compute layer (derived entries)
|
|
233
267
|
|
|
234
268
|
Derived entries live in a zone whose `write_policy:` list includes `builder` — `output` in the default scaffold. They are not authored by hand; their body is produced by projecting over other entries. A derived entry declares a `compute:` block with a `kind:` discriminator.
|
|
@@ -399,7 +433,7 @@ Schema (one JSON object per line, no interior whitespace):
|
|
|
399
433
|
{"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
|
|
400
434
|
```
|
|
401
435
|
|
|
402
|
-
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `delete`, `accept`, `compute`, `
|
|
436
|
+
`ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the audit-log payload string identifying the operation (`put`, `delete`, `accept`, `compute`, `mv`, ...). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag).
|
|
403
437
|
|
|
404
438
|
For `mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
|
|
405
439
|
|
|
@@ -705,7 +739,6 @@ All verbs accept `--output=json` and emit a canonical envelope (success or error
|
|
|
705
739
|
| `init` | write | `human` |
|
|
706
740
|
| `schema {show,init,diff,migrate}` | read/write | `human` for writes |
|
|
707
741
|
| `key mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
|
|
708
|
-
| `key normalize [--dry-run\|--write]` | write (with `--write`) | `human` |
|
|
709
742
|
| `key uid K` | read | any |
|
|
710
743
|
|
|
711
744
|
**`put` input** (read from stdin when `--stdin` is given):
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
# Promotion predicate: the role driving the promotion must have
|
|
6
|
+
# role_kind == :accept_authority in the active manifest.
|
|
7
|
+
#
|
|
8
|
+
# Accept/Reject already gate on this kind before reaching the
|
|
9
|
+
# promotion policy, so in the default control-flow this predicate
|
|
10
|
+
# trivially passes. It is kept so manifests can express the
|
|
11
|
+
# requirement explicitly in `rules[].promotion.requires`.
|
|
12
|
+
class AcceptAuthoritySigned
|
|
13
|
+
attr_reader :reason
|
|
14
|
+
|
|
15
|
+
def name
|
|
16
|
+
"accept_authority_signed"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(role:, manifest:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
20
|
+
role_str = role&.to_s
|
|
21
|
+
return true if role_str.nil? || role_str.empty?
|
|
22
|
+
|
|
23
|
+
kind = manifest.role_kind(role_str)
|
|
24
|
+
return true if kind == :accept_authority
|
|
25
|
+
|
|
26
|
+
@reason = "role '#{role_str}' has kind '#{kind.inspect}', expected ':accept_authority'"
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -1,19 +1,15 @@
|
|
|
1
|
+
require_relative "predicates/schema_valid"
|
|
2
|
+
require_relative "predicates/accept_authority_signed"
|
|
3
|
+
|
|
1
4
|
module Textus
|
|
2
5
|
module Application
|
|
3
6
|
module Policy
|
|
4
|
-
# Promotion evaluates a list of named predicates against a pending-proposal
|
|
5
|
-
# entry and returns a Result indicating whether all requirements are met.
|
|
6
|
-
#
|
|
7
|
-
# Lives in Application because the predicates it wires up read live state
|
|
8
|
-
# from explicit ports (schemas, manifest, role). The Domain-side rule
|
|
9
|
-
# statement ("this policy requires predicates X and Y") is captured by
|
|
10
|
-
# Textus::Domain::Policy::Promote.
|
|
11
7
|
class Promotion
|
|
12
8
|
Result = Struct.new(:ok?, :reasons, keyword_init: true)
|
|
13
9
|
|
|
14
10
|
REGISTRY = {
|
|
15
11
|
"schema_valid" => -> { Predicates::SchemaValid.new },
|
|
16
|
-
"
|
|
12
|
+
"accept_authority_signed" => -> { Predicates::AcceptAuthoritySigned.new },
|
|
17
13
|
}.freeze
|
|
18
14
|
|
|
19
15
|
def self.from_names(names)
|
|
@@ -49,10 +45,9 @@ module Textus
|
|
|
49
45
|
|
|
50
46
|
def invoke(pred, entry:, schemas:, manifest:, role:)
|
|
51
47
|
case pred.name
|
|
52
|
-
when "
|
|
53
|
-
pred.call(role: role, entry: entry)
|
|
48
|
+
when "accept_authority_signed"
|
|
49
|
+
pred.call(role: role, manifest: manifest, entry: entry)
|
|
54
50
|
else
|
|
55
|
-
# Default shape: schema-style predicates that need entry + schemas + manifest.
|
|
56
51
|
pred.call(entry: entry, schemas: schemas, manifest: manifest)
|
|
57
52
|
end
|
|
58
53
|
end
|
|
@@ -55,9 +55,11 @@ module Textus
|
|
|
55
55
|
last_writer = @audit_log.last_writer_for(key)
|
|
56
56
|
return if last_writer.nil?
|
|
57
57
|
|
|
58
|
+
last_writer_is_authority = @manifest.role_kind(last_writer) == :accept_authority
|
|
59
|
+
|
|
58
60
|
env.meta.each_key do |field|
|
|
59
61
|
owner = schema.maintained_by(field)
|
|
60
|
-
next if owner.nil? || last_writer == owner ||
|
|
62
|
+
next if owner.nil? || last_writer == owner || last_writer_is_authority
|
|
61
63
|
|
|
62
64
|
violations << { "key" => key, "code" => "role_authority",
|
|
63
65
|
"field" => field, "expected" => owner, "last_writer" => last_writer }
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
require_relative "authority_gate"
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
4
|
module Application
|
|
3
5
|
module Writes
|
|
4
6
|
class Accept
|
|
7
|
+
include AuthorityGate
|
|
8
|
+
|
|
5
9
|
def initialize(ctx:, manifest:, file_store:, schemas:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
6
10
|
@ctx = ctx
|
|
7
11
|
@manifest = manifest
|
|
@@ -14,7 +18,7 @@ module Textus
|
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
def call(pending_key)
|
|
17
|
-
|
|
21
|
+
assert_accept_authority!("accept")
|
|
18
22
|
|
|
19
23
|
env = Textus::Application::Reads::Get.new(
|
|
20
24
|
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Writes
|
|
4
|
+
# Shared gate for write verbs that require the caller to hold the
|
|
5
|
+
# manifest's accept_authority role. Provides one method, expressed
|
|
6
|
+
# as two early-returns rather than a ternary, so each failure mode
|
|
7
|
+
# reads on its own line.
|
|
8
|
+
module AuthorityGate
|
|
9
|
+
def assert_accept_authority!(verb)
|
|
10
|
+
return if @manifest.role_kind(@ctx.role) == :accept_authority
|
|
11
|
+
|
|
12
|
+
authority = @manifest.roles_with_kind(:accept_authority).first
|
|
13
|
+
if authority.nil?
|
|
14
|
+
raise ProposalError.new(
|
|
15
|
+
"no role with accept_authority kind is declared in this manifest; #{verb} is disabled",
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
raise ProposalError.new(
|
|
20
|
+
"only #{authority} role can #{verb} proposals; got '#{@ctx.role}'",
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -58,7 +58,7 @@ module Textus
|
|
|
58
58
|
).run(mentry)
|
|
59
59
|
|
|
60
60
|
publish_derived_copies(mentry, target_path, repo_root)
|
|
61
|
-
fire_build_completed(mentry
|
|
61
|
+
fire_build_completed(mentry)
|
|
62
62
|
|
|
63
63
|
{ "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
|
|
64
64
|
end
|
|
@@ -76,7 +76,7 @@ module Textus
|
|
|
76
76
|
end
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
-
def fire_build_completed(mentry
|
|
79
|
+
def fire_build_completed(mentry)
|
|
80
80
|
envelope = reader.call(mentry.key)
|
|
81
81
|
src = mentry.source
|
|
82
82
|
selects = src.is_a?(Textus::Manifest::Entry::Derived::Projection) ? Array(src.select).compact : []
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
require_relative "authority_gate"
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
4
|
module Application
|
|
3
5
|
module Writes
|
|
4
6
|
class Reject
|
|
7
|
+
include AuthorityGate
|
|
8
|
+
|
|
5
9
|
def initialize(ctx:, manifest:, file_store:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
6
10
|
@ctx = ctx
|
|
7
11
|
@manifest = manifest
|
|
@@ -13,7 +17,7 @@ module Textus
|
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def call(pending_key)
|
|
16
|
-
|
|
20
|
+
assert_accept_authority!("reject")
|
|
17
21
|
|
|
18
22
|
mentry = @manifest.resolver.resolve(pending_key).entry
|
|
19
23
|
unless mentry.in_proposal_zone?
|
|
@@ -8,7 +8,8 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
10
|
Textus::Infra::BuildLock.with(root: store.root) do
|
|
11
|
-
|
|
11
|
+
role = store.manifest.roles_with_kind(:generator).first || "builder"
|
|
12
|
+
ops = Textus::Operations.for(store, role: role)
|
|
12
13
|
result = ops.publish(prefix: prefix)
|
|
13
14
|
emit(result)
|
|
14
15
|
end
|
|
@@ -44,15 +44,14 @@ module Textus
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def issue(abs_path, stem)
|
|
47
|
-
proposed = Textus::Application::Tools::MigrateKeys.normalize(stem)
|
|
48
47
|
{
|
|
49
48
|
"code" => "key.illegal",
|
|
50
49
|
"level" => "error",
|
|
51
50
|
"subject" => abs_path,
|
|
52
51
|
"path" => abs_path,
|
|
53
|
-
"proposed_key" => proposed,
|
|
54
52
|
"message" => "illegal key segment '#{stem}' at #{abs_path}",
|
|
55
|
-
"fix" => "
|
|
53
|
+
"fix" => "rename the file/directory so each segment matches [a-z0-9][a-z0-9-]* " \
|
|
54
|
+
"(lowercase, digits, hyphens)",
|
|
56
55
|
}
|
|
57
56
|
end
|
|
58
57
|
|
|
@@ -2,14 +2,16 @@ module Textus
|
|
|
2
2
|
module Domain
|
|
3
3
|
module Policy
|
|
4
4
|
class Promote
|
|
5
|
-
KNOWN = %i[schema_valid
|
|
5
|
+
KNOWN = %i[schema_valid accept_authority_signed].freeze
|
|
6
6
|
attr_reader :requires
|
|
7
7
|
|
|
8
8
|
def initialize(requires:)
|
|
9
9
|
syms = Array(requires).map { |r| r.to_s.to_sym }
|
|
10
10
|
unknown = syms - KNOWN
|
|
11
11
|
unless unknown.empty?
|
|
12
|
-
raise Textus::UsageError.new(
|
|
12
|
+
raise Textus::UsageError.new(
|
|
13
|
+
"unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})",
|
|
14
|
+
)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
@requires = syms
|
|
@@ -2,6 +2,8 @@ module Textus
|
|
|
2
2
|
module Domain
|
|
3
3
|
module Policy
|
|
4
4
|
class Refresh
|
|
5
|
+
ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
|
|
6
|
+
|
|
5
7
|
attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
|
|
6
8
|
|
|
7
9
|
def initialize(ttl:, on_stale:, sync_budget_ms:, fetch_timeout_seconds: nil)
|
data/lib/textus/intro.rb
CHANGED
|
@@ -18,19 +18,37 @@ module Textus
|
|
|
18
18
|
"output" => "build-computed outputs; never hand-edited",
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
# Per-kind write-flow templates. Each lambda receives the user-facing role
|
|
22
|
+
# name and returns a guidance string for that role. Roles whose kind has
|
|
23
|
+
# no template (e.g. unknown future kinds) are omitted from write_flows.
|
|
24
|
+
WRITE_FLOW_TEMPLATES = {
|
|
25
|
+
accept_authority: lambda do |name, _manifest|
|
|
26
|
+
"edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
|
|
27
|
+
end,
|
|
28
|
+
proposer: lambda do |name, manifest|
|
|
29
|
+
authority = manifest.roles_with_kind(:accept_authority).first || "accept_authority"
|
|
30
|
+
"propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
|
|
31
|
+
"the #{authority} role runs 'textus accept' to apply"
|
|
32
|
+
end,
|
|
33
|
+
runner: lambda do |name, _manifest|
|
|
34
|
+
"refresh intake entries with 'textus refresh KEY --as=#{name}' (uses the entry's declared action)"
|
|
35
|
+
end,
|
|
36
|
+
generator: lambda do |_name, _manifest|
|
|
37
|
+
"'textus build' computes output entries from projections; output files are never hand-edited"
|
|
38
|
+
end,
|
|
27
39
|
}.freeze
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
def self.write_flows_for(manifest)
|
|
42
|
+
manifest.role_mapping.each_with_object({}) do |(name, kind), acc|
|
|
43
|
+
tmpl = WRITE_FLOW_TEMPLATES[kind]
|
|
44
|
+
acc[name] = tmpl.call(name, manifest) if tmpl
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Static, store-independent parts of the agent-facing protocol. The
|
|
49
|
+
# `role_resolution` block is derived per-manifest in agent_protocol(...)
|
|
50
|
+
# because role names are user-configurable.
|
|
51
|
+
AGENT_PROTOCOL_TEMPLATE = {
|
|
34
52
|
"envelope_shape" => {
|
|
35
53
|
"summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
|
|
36
54
|
"fields" => {
|
|
@@ -41,11 +59,6 @@ module Textus
|
|
|
41
59
|
},
|
|
42
60
|
"ref" => "SPEC.md §8",
|
|
43
61
|
},
|
|
44
|
-
"role_resolution" => {
|
|
45
|
-
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, default human",
|
|
46
|
-
"roles" => %w[human agent runner builder],
|
|
47
|
-
"ref" => "SPEC.md §5",
|
|
48
|
-
},
|
|
49
62
|
"recipes" => {
|
|
50
63
|
"read" => {
|
|
51
64
|
"purpose" => "find and read an entry",
|
|
@@ -92,7 +105,7 @@ module Textus
|
|
|
92
105
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
93
106
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
94
107
|
{ "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
|
|
95
|
-
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'
|
|
108
|
+
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
|
|
96
109
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
97
110
|
{ "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
|
|
98
111
|
{ "name" => "refresh", "summary" => "run an action for an intake entry" },
|
|
@@ -105,6 +118,17 @@ module Textus
|
|
|
105
118
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
106
119
|
].freeze
|
|
107
120
|
|
|
121
|
+
def self.agent_protocol(manifest)
|
|
122
|
+
AGENT_PROTOCOL_TEMPLATE.merge(
|
|
123
|
+
"role_resolution" => {
|
|
124
|
+
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
|
|
125
|
+
"default 'human'",
|
|
126
|
+
"roles" => manifest.role_mapping.keys,
|
|
127
|
+
"ref" => "SPEC.md §5",
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
108
132
|
def self.run(store)
|
|
109
133
|
{
|
|
110
134
|
"protocol" => PROTOCOL_ID,
|
|
@@ -112,9 +136,9 @@ module Textus
|
|
|
112
136
|
"zones" => zones_for(store),
|
|
113
137
|
"entries" => entries_for(store),
|
|
114
138
|
"hooks" => hooks_for(store),
|
|
115
|
-
"write_flows" =>
|
|
139
|
+
"write_flows" => write_flows_for(store.manifest),
|
|
116
140
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
117
|
-
"agent_protocol" =>
|
|
141
|
+
"agent_protocol" => agent_protocol(store.manifest),
|
|
118
142
|
"docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
|
|
119
143
|
}
|
|
120
144
|
end
|
|
@@ -130,7 +154,7 @@ module Textus
|
|
|
130
154
|
|
|
131
155
|
def self.entries_for(store)
|
|
132
156
|
store.manifest.entries.map do |e|
|
|
133
|
-
derived = store.manifest.
|
|
157
|
+
derived = store.manifest.zone_kinds(e.zone).include?(:generator)
|
|
134
158
|
{
|
|
135
159
|
"key" => e.key,
|
|
136
160
|
"zone" => e.zone,
|
|
@@ -25,8 +25,8 @@ module Textus
|
|
|
25
25
|
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def in_generator_zone? =
|
|
29
|
-
def in_proposal_zone? =
|
|
28
|
+
def in_generator_zone? = @manifest.zone_kinds(@zone).include?(:generator)
|
|
29
|
+
def in_proposal_zone? = @manifest.zone_kinds(@zone).include?(:proposer)
|
|
30
30
|
|
|
31
31
|
def nested? = false
|
|
32
32
|
def derived? = false
|
|
@@ -64,13 +64,7 @@ module Textus
|
|
|
64
64
|
|
|
65
65
|
def self.parse_source(raw, key)
|
|
66
66
|
compute = raw["compute"]
|
|
67
|
-
if compute.nil?
|
|
68
|
-
# Tolerate legacy derived entries with bare template (no compute block):
|
|
69
|
-
# treat as projection with no select.
|
|
70
|
-
return Derived::Projection.new(select: nil, pluck: nil, sort_by: nil, transform: nil) if raw["template"]
|
|
71
|
-
|
|
72
|
-
raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:")
|
|
73
|
-
end
|
|
67
|
+
raise BadManifest.new("derived entry '#{key}' requires compute: { kind: projection|external } or template:") if compute.nil?
|
|
74
68
|
|
|
75
69
|
unless COMPUTE_KINDS.include?(compute["kind"])
|
|
76
70
|
raise BadManifest.new(
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Resolver
|
|
4
|
+
Resolution = Data.define(:entry, :path, :remaining)
|
|
5
|
+
|
|
4
6
|
def initialize(manifest)
|
|
5
7
|
@manifest = manifest
|
|
6
8
|
end
|
|
@@ -83,7 +85,8 @@ module Textus
|
|
|
83
85
|
|
|
84
86
|
illegal = segs.find { |s| !valid_segment?(s) }
|
|
85
87
|
if illegal
|
|
86
|
-
warn("textus: skipping illegal key segment '#{illegal}' at #{path} —
|
|
88
|
+
warn("textus: skipping illegal key segment '#{illegal}' at #{path} — " \
|
|
89
|
+
"rename to match [a-z0-9][a-z0-9-]* (run 'textus doctor' for the full list)")
|
|
87
90
|
return nil
|
|
88
91
|
end
|
|
89
92
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
module RoleKinds
|
|
4
|
+
DEFAULT_MAPPING = {
|
|
5
|
+
"human" => :accept_authority,
|
|
6
|
+
"agent" => :proposer,
|
|
7
|
+
"builder" => :generator,
|
|
8
|
+
"runner" => :runner,
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
# Returns { role_name => kind_symbol }. When `roles:` is declared we use
|
|
12
|
+
# exactly that; defaults are *not* layered in (declaring roles is an opt-in
|
|
13
|
+
# to a fully user-defined vocabulary).
|
|
14
|
+
def self.resolve(raw_roles)
|
|
15
|
+
return DEFAULT_MAPPING if raw_roles.nil?
|
|
16
|
+
|
|
17
|
+
raw_roles.to_h { |r| [r["name"], r["kind"].to_sym] }.freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
module Schema
|
|
4
|
-
ROOT_KEYS = %w[version zones entries rules].freeze
|
|
4
|
+
ROOT_KEYS = %w[version roles zones entries rules].freeze
|
|
5
|
+
ROLE_KEYS = %w[name kind].freeze
|
|
6
|
+
ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
|
|
5
7
|
ZONE_KEYS = %w[name write_policy read_policy].freeze
|
|
6
8
|
ENTRY_KEYS = %w[
|
|
7
9
|
key path zone kind schema owner nested format
|
|
@@ -19,6 +21,7 @@ module Textus
|
|
|
19
21
|
raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
|
|
20
22
|
|
|
21
23
|
walk(raw, ROOT_KEYS, "$")
|
|
24
|
+
validate_roles!(raw["roles"])
|
|
22
25
|
Array(raw["zones"]).each_with_index do |z, i|
|
|
23
26
|
walk(z, ZONE_KEYS, "$.zones[#{i}]")
|
|
24
27
|
end
|
|
@@ -37,6 +40,47 @@ module Textus
|
|
|
37
40
|
end
|
|
38
41
|
walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
|
|
39
42
|
end
|
|
43
|
+
validate_zone_writers_declared!(raw)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.validate_zone_writers_declared!(raw)
|
|
47
|
+
return if raw["roles"].nil? # default mapping is permissive
|
|
48
|
+
|
|
49
|
+
declared = Array(raw["roles"]).map { |r| r["name"] }.compact.to_set
|
|
50
|
+
Array(raw["zones"]).each do |z|
|
|
51
|
+
Array(z["write_policy"]).each_with_index do |w, j|
|
|
52
|
+
next if declared.include?(w)
|
|
53
|
+
|
|
54
|
+
raise BadManifest.new(
|
|
55
|
+
"zone '#{z["name"]}' write_policy[#{j}] references undeclared role '#{w}' " \
|
|
56
|
+
"(declared roles: #{declared.to_a.join(", ")})",
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.validate_roles!(roles)
|
|
63
|
+
return if roles.nil?
|
|
64
|
+
raise BadManifest.new("roles: must be a list") unless roles.is_a?(Array)
|
|
65
|
+
|
|
66
|
+
accept_authority_count = 0
|
|
67
|
+
roles.each_with_index do |r, i|
|
|
68
|
+
path = "$.roles[#{i}]"
|
|
69
|
+
walk(r, ROLE_KEYS, path)
|
|
70
|
+
name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
|
|
71
|
+
kind = r["kind"] or raise BadManifest.new("role '#{name}' at '#{path}' missing kind")
|
|
72
|
+
unless ROLE_KINDS.include?(kind)
|
|
73
|
+
raise BadManifest.new("unknown role kind '#{kind}' at '#{path}' (known: #{ROLE_KINDS.join(", ")})")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
accept_authority_count += 1 if kind == "accept_authority"
|
|
77
|
+
end
|
|
78
|
+
return unless accept_authority_count > 1
|
|
79
|
+
|
|
80
|
+
raise BadManifest.new(
|
|
81
|
+
"manifest declares #{accept_authority_count} accept_authority roles; " \
|
|
82
|
+
"at most one accept_authority role is allowed",
|
|
83
|
+
)
|
|
40
84
|
end
|
|
41
85
|
|
|
42
86
|
def self.validate_fetch_timeout!(value, path)
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "yaml"
|
|
2
2
|
require_relative "manifest/schema"
|
|
3
|
-
require_relative "manifest/resolution"
|
|
4
3
|
require_relative "manifest/resolver"
|
|
4
|
+
require_relative "manifest/role_kinds"
|
|
5
5
|
|
|
6
6
|
module Textus
|
|
7
7
|
class Manifest
|
|
@@ -30,6 +30,26 @@ module Textus
|
|
|
30
30
|
)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
def role_mapping
|
|
34
|
+
@role_mapping ||= RoleKinds.resolve(@raw["roles"])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def role_kind(name)
|
|
38
|
+
role_mapping[name]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def roles_with_kind(kind)
|
|
42
|
+
role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def zone_kinds(zone_name)
|
|
46
|
+
@zone_kinds_cache ||= {}
|
|
47
|
+
@zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
|
|
48
|
+
k = role_kind(w)
|
|
49
|
+
acc << k if k
|
|
50
|
+
end.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
33
53
|
def self.parse(yaml_text, root: ".")
|
|
34
54
|
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
35
55
|
check_version!(raw, "<string>")
|
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -49,7 +49,14 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
authority = store.manifest.roles_with_kind(:accept_authority).first
|
|
53
|
+
if authority.nil?
|
|
54
|
+
raise UsageError.new(
|
|
55
|
+
"schema migrate requires a role with kind :accept_authority in the manifest; " \
|
|
56
|
+
"none declared (add e.g. `- { name: owner, kind: accept_authority }` to roles:)",
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
ops = Textus::Operations.for(store, role: authority)
|
|
53
60
|
touched = []
|
|
54
61
|
store.manifest.resolver.enumerate.each do |row|
|
|
55
62
|
env = ops.get(row[:key])
|
data/lib/textus/version.rb
CHANGED
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.20.
|
|
4
|
+
version: 0.20.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
@@ -110,7 +110,7 @@ files:
|
|
|
110
110
|
- exe/textus
|
|
111
111
|
- lib/textus.rb
|
|
112
112
|
- lib/textus/application/context.rb
|
|
113
|
-
- lib/textus/application/policy/predicates/
|
|
113
|
+
- lib/textus/application/policy/predicates/accept_authority_signed.rb
|
|
114
114
|
- lib/textus/application/policy/predicates/schema_valid.rb
|
|
115
115
|
- lib/textus/application/policy/promotion.rb
|
|
116
116
|
- lib/textus/application/projection.rb
|
|
@@ -133,9 +133,8 @@ files:
|
|
|
133
133
|
- lib/textus/application/refresh/all.rb
|
|
134
134
|
- lib/textus/application/refresh/orchestrator.rb
|
|
135
135
|
- lib/textus/application/refresh/worker.rb
|
|
136
|
-
- lib/textus/application/tools/migrate_keys.rb
|
|
137
|
-
- lib/textus/application/tools/migrate_manifest_to_kinds.rb
|
|
138
136
|
- lib/textus/application/writes/accept.rb
|
|
137
|
+
- lib/textus/application/writes/authority_gate.rb
|
|
139
138
|
- lib/textus/application/writes/delete.rb
|
|
140
139
|
- lib/textus/application/writes/envelope_io.rb
|
|
141
140
|
- lib/textus/application/writes/materializer.rb
|
|
@@ -170,7 +169,6 @@ files:
|
|
|
170
169
|
- lib/textus/cli/verb/hooks.rb
|
|
171
170
|
- lib/textus/cli/verb/init.rb
|
|
172
171
|
- lib/textus/cli/verb/intro.rb
|
|
173
|
-
- lib/textus/cli/verb/key_normalize.rb
|
|
174
172
|
- lib/textus/cli/verb/list.rb
|
|
175
173
|
- lib/textus/cli/verb/mv.rb
|
|
176
174
|
- lib/textus/cli/verb/published.rb
|
|
@@ -212,7 +210,6 @@ files:
|
|
|
212
210
|
- lib/textus/domain/freshness/verdict.rb
|
|
213
211
|
- lib/textus/domain/outcome.rb
|
|
214
212
|
- lib/textus/domain/permission.rb
|
|
215
|
-
- lib/textus/domain/policy.rb
|
|
216
213
|
- lib/textus/domain/policy/handler_allowlist.rb
|
|
217
214
|
- lib/textus/domain/policy/matcher.rb
|
|
218
215
|
- lib/textus/domain/policy/promote.rb
|
|
@@ -263,8 +260,8 @@ files:
|
|
|
263
260
|
- lib/textus/manifest/entry/validators/index_filename.rb
|
|
264
261
|
- lib/textus/manifest/entry/validators/inject_intro.rb
|
|
265
262
|
- lib/textus/manifest/entry/validators/publish_each.rb
|
|
266
|
-
- lib/textus/manifest/resolution.rb
|
|
267
263
|
- lib/textus/manifest/resolver.rb
|
|
264
|
+
- lib/textus/manifest/role_kinds.rb
|
|
268
265
|
- lib/textus/manifest/rules.rb
|
|
269
266
|
- lib/textus/manifest/schema.rb
|
|
270
267
|
- lib/textus/mustache.rb
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Application
|
|
3
|
-
module Policy
|
|
4
|
-
module Predicates
|
|
5
|
-
class HumanAccept
|
|
6
|
-
attr_reader :reason
|
|
7
|
-
|
|
8
|
-
def name
|
|
9
|
-
"human_accept"
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
# The role is passed explicitly. In practice, Accept already enforces
|
|
13
|
-
# role == "human" before reaching the promotion gate, so this predicate
|
|
14
|
-
# trivially passes. It documents intent and future-proofs multi-actor
|
|
15
|
-
# accept flows.
|
|
16
|
-
def call(role:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
17
|
-
role_str = role&.to_s
|
|
18
|
-
# If we cannot determine the role, trust that Accept has already
|
|
19
|
-
# checked — allow through.
|
|
20
|
-
return true if role_str.nil? || role_str.empty?
|
|
21
|
-
|
|
22
|
-
ok = (role_str == "human")
|
|
23
|
-
@reason = "current role is '#{role_str}', expected 'human'" unless ok
|
|
24
|
-
ok
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Application
|
|
3
|
-
module Tools
|
|
4
|
-
# Run-once helper that renames files/directories whose basenames don't
|
|
5
|
-
# conform to the strict key grammar (§3 of plan-1.2). Only walks
|
|
6
|
-
# nested: true manifest entries — leaf entries with illegal declared
|
|
7
|
-
# keys are caught by Manifest load and must be fixed by hand.
|
|
8
|
-
module MigrateKeys
|
|
9
|
-
SEGMENT = /\A[a-z0-9][a-z0-9-]*\z/
|
|
10
|
-
|
|
11
|
-
module_function
|
|
12
|
-
|
|
13
|
-
# Returns the envelope hash described in plan-1.2 §3.
|
|
14
|
-
def run(store, write: false)
|
|
15
|
-
plan = build_plan(store)
|
|
16
|
-
collisions = plan[:collisions]
|
|
17
|
-
renames = plan[:renames]
|
|
18
|
-
|
|
19
|
-
ok = collisions.empty?
|
|
20
|
-
apply!(store, renames) if write && ok
|
|
21
|
-
|
|
22
|
-
{
|
|
23
|
-
"protocol" => Textus::PROTOCOL,
|
|
24
|
-
"mode" => write ? "write" : "dry-run",
|
|
25
|
-
"renames" => renames.map { |r| envelope_rename(r) },
|
|
26
|
-
"collisions" => collisions.map { |c| envelope_collision(c) },
|
|
27
|
-
"ok" => ok,
|
|
28
|
-
}
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# ------------------------------------------------------------------
|
|
32
|
-
# Plan construction
|
|
33
|
-
# ------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
# Returns { renames: [...], collisions: [...] }
|
|
36
|
-
# Each rename: { from:, to:, old_key:, new_key:, kind: :file|:dir }
|
|
37
|
-
# Each collision: { target:, sources: [...] }
|
|
38
|
-
def build_plan(store) # rubocop:disable Metrics/AbcSize
|
|
39
|
-
renames = []
|
|
40
|
-
target_buckets = Hash.new { |h, k| h[k] = [] } # target_path => [source_path, ...]
|
|
41
|
-
|
|
42
|
-
store.manifest.entries.each do |entry|
|
|
43
|
-
next unless entry.nested?
|
|
44
|
-
|
|
45
|
-
base = File.join(store.root, "zones", entry.path)
|
|
46
|
-
next unless File.directory?(base)
|
|
47
|
-
|
|
48
|
-
# Walk depth-first. Order matters when computing the "new key"
|
|
49
|
-
# for files inside a renamed directory: we record renames bottom-up,
|
|
50
|
-
# so children are renamed before their parents on apply.
|
|
51
|
-
walk(base) do |abs_path, is_dir|
|
|
52
|
-
next if abs_path == base
|
|
53
|
-
|
|
54
|
-
basename = File.basename(abs_path)
|
|
55
|
-
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
56
|
-
next if stem.match?(SEGMENT)
|
|
57
|
-
|
|
58
|
-
new_stem = normalize(stem)
|
|
59
|
-
# Skip if normalization yields the same stem (e.g. already-legal
|
|
60
|
-
# under a different lens). In practice match?(SEGMENT) catches that
|
|
61
|
-
# above; this is a safety net.
|
|
62
|
-
next if new_stem == stem
|
|
63
|
-
|
|
64
|
-
new_basename = is_dir ? new_stem : new_stem + File.extname(basename)
|
|
65
|
-
target = File.join(File.dirname(abs_path), new_basename)
|
|
66
|
-
target_buckets[target] << abs_path
|
|
67
|
-
|
|
68
|
-
renames << {
|
|
69
|
-
from: abs_path,
|
|
70
|
-
to: target,
|
|
71
|
-
kind: is_dir ? :dir : :file,
|
|
72
|
-
entry: entry,
|
|
73
|
-
base: base,
|
|
74
|
-
}
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
collisions = target_buckets.select { |_, srcs| srcs.length > 1 }
|
|
79
|
-
.map { |t, srcs| { target: t, sources: srcs.sort } }
|
|
80
|
-
|
|
81
|
-
# Drop colliding entries from renames (we won't apply any of them)
|
|
82
|
-
colliding_targets = collisions.to_set { |c| c[:target] }
|
|
83
|
-
renames.reject! { |r| colliding_targets.include?(r[:to]) }
|
|
84
|
-
|
|
85
|
-
# Sort renames bottom-up (deepest path first) so children move before parents.
|
|
86
|
-
renames.sort_by! { |r| -r[:from].count("/") }
|
|
87
|
-
|
|
88
|
-
{ renames: renames, collisions: collisions }
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Yields [absolute_path, is_dir] for every entry under root. Depth-first.
|
|
92
|
-
def walk(root, &block)
|
|
93
|
-
Dir.each_child(root) do |name|
|
|
94
|
-
abs = File.join(root, name)
|
|
95
|
-
if File.directory?(abs)
|
|
96
|
-
walk(abs, &block)
|
|
97
|
-
yield abs, true
|
|
98
|
-
else
|
|
99
|
-
yield abs, false
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Deterministic transform per plan §3.
|
|
105
|
-
def normalize(s)
|
|
106
|
-
s = s.downcase
|
|
107
|
-
s = s.gsub(/[^a-z0-9-]/, "-") # ., _, and anything else become -
|
|
108
|
-
s = s.gsub(/-+/, "-")
|
|
109
|
-
s.sub(/\A-+/, "").sub(/-+\z/, "")
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# ------------------------------------------------------------------
|
|
113
|
-
# Apply
|
|
114
|
-
# ------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
def apply!(store, renames)
|
|
117
|
-
audit = Textus::Infra::AuditLog.new(store.root)
|
|
118
|
-
renames.each do |r|
|
|
119
|
-
# Bottom-up order means a child's ancestors haven't moved yet, so
|
|
120
|
-
# `from`/`to` are valid as-recorded. The audit `key` reflects the
|
|
121
|
-
# eventual full key once every rename in this batch has applied.
|
|
122
|
-
from = r[:from]
|
|
123
|
-
to = r[:to]
|
|
124
|
-
File.rename(from, to)
|
|
125
|
-
new_key = compute_new_key(r, renames)
|
|
126
|
-
audit.append(
|
|
127
|
-
role: "runner",
|
|
128
|
-
verb: "migrate-keys",
|
|
129
|
-
key: new_key,
|
|
130
|
-
etag_before: nil,
|
|
131
|
-
etag_after: nil,
|
|
132
|
-
extras: { "from" => from, "to" => to },
|
|
133
|
-
)
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
# If an ancestor of `path` was renamed earlier in this batch, rewrite the path.
|
|
138
|
-
def resolve_current_path(path, renames)
|
|
139
|
-
out = path
|
|
140
|
-
renames.each do |r|
|
|
141
|
-
prefix = r[:from] + "/"
|
|
142
|
-
out = r[:to] + out[r[:from].length..] if out.start_with?(prefix)
|
|
143
|
-
end
|
|
144
|
-
out
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# New full key after applying all renames up through this one.
|
|
148
|
-
def compute_new_key(rename, renames)
|
|
149
|
-
base = rename[:base]
|
|
150
|
-
entry = rename[:entry]
|
|
151
|
-
new_to = resolve_current_path(rename[:to], renames)
|
|
152
|
-
|
|
153
|
-
rel = new_to.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
154
|
-
stripped = rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "") unless rename[:kind] == :dir
|
|
155
|
-
stripped ||= rel
|
|
156
|
-
segs = stripped.split("/").reject(&:empty?)
|
|
157
|
-
(entry.key.split(".") + segs).join(".")
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
# ------------------------------------------------------------------
|
|
161
|
-
# Envelope helpers
|
|
162
|
-
# ------------------------------------------------------------------
|
|
163
|
-
|
|
164
|
-
def envelope_rename(r)
|
|
165
|
-
{
|
|
166
|
-
"from" => r[:from],
|
|
167
|
-
"to" => r[:to],
|
|
168
|
-
"old_key" => path_to_key(r[:from], r[:base], r[:entry], r[:kind]),
|
|
169
|
-
"new_key" => path_to_key(r[:to], r[:base], r[:entry], r[:kind]),
|
|
170
|
-
}
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def envelope_collision(col)
|
|
174
|
-
{ "target" => col[:target], "sources" => col[:sources] }
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def path_to_key(path, base, entry, kind)
|
|
178
|
-
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
179
|
-
stripped =
|
|
180
|
-
if kind == :dir
|
|
181
|
-
rel
|
|
182
|
-
else
|
|
183
|
-
rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
184
|
-
end
|
|
185
|
-
segs = stripped.split("/").reject(&:empty?)
|
|
186
|
-
(entry.key.split(".") + segs).join(".")
|
|
187
|
-
end
|
|
188
|
-
end
|
|
189
|
-
end
|
|
190
|
-
end
|
|
191
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
require "yaml"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Application
|
|
5
|
-
module Tools
|
|
6
|
-
module MigrateManifestToKinds
|
|
7
|
-
module_function
|
|
8
|
-
|
|
9
|
-
def upgrade_yaml(yaml_text)
|
|
10
|
-
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
11
|
-
raw["entries"] = Array(raw["entries"]).map { |row| upgrade_row(row) }
|
|
12
|
-
YAML.dump(raw)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def upgrade_row(row)
|
|
16
|
-
return row if row["kind"]
|
|
17
|
-
|
|
18
|
-
row.merge("kind" => infer_kind(row))
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def infer_kind(row)
|
|
22
|
-
return "intake" if row["intake"].is_a?(Hash) || row["intake_handler"]
|
|
23
|
-
return "derived" if row["template"] || row["compute"] || row["generator"] || row["projection"]
|
|
24
|
-
return "nested" if row["nested"] == true
|
|
25
|
-
|
|
26
|
-
"leaf"
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class CLI
|
|
3
|
-
class Verb
|
|
4
|
-
class KeyNormalize < Verb
|
|
5
|
-
command_name "normalize"
|
|
6
|
-
parent_group Group::Key
|
|
7
|
-
|
|
8
|
-
option :write, "--write"
|
|
9
|
-
option :dry_run, "--dry-run"
|
|
10
|
-
option :upgrade_manifest, "--upgrade-manifest"
|
|
11
|
-
|
|
12
|
-
def call(store)
|
|
13
|
-
if upgrade_manifest
|
|
14
|
-
run_upgrade_manifest(store)
|
|
15
|
-
else
|
|
16
|
-
effective_write = write && !dry_run
|
|
17
|
-
res = Textus::Application::Tools::MigrateKeys.run(store, write: effective_write || false)
|
|
18
|
-
emit(res, exit_code: res["ok"] ? 0 : 1)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
def run_upgrade_manifest(store)
|
|
25
|
-
manifest_path = File.join(store.root, "manifest.yaml")
|
|
26
|
-
orig = File.read(manifest_path)
|
|
27
|
-
new_yaml = Textus::Application::Tools::MigrateManifestToKinds.upgrade_yaml(orig)
|
|
28
|
-
|
|
29
|
-
if dry_run
|
|
30
|
-
diff_lines = unified_diff(orig, new_yaml, manifest_path)
|
|
31
|
-
emit({ "protocol" => PROTOCOL, "dry_run" => true, "diff" => diff_lines, "ok" => true }, exit_code: 0)
|
|
32
|
-
else
|
|
33
|
-
File.write(manifest_path, new_yaml)
|
|
34
|
-
puts "upgraded manifest at #{manifest_path}"
|
|
35
|
-
emit({ "protocol" => PROTOCOL, "upgraded" => manifest_path, "ok" => true }, exit_code: 0)
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def unified_diff(before, after, _path)
|
|
40
|
-
before.lines.zip(after.lines).each_with_object([]) do |(a, b), acc|
|
|
41
|
-
acc << "- #{a.chomp}" if a && a != b
|
|
42
|
-
acc << "+ #{b.chomp}" if b && a != b
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
data/lib/textus/domain/policy.rb
DELETED