textus 0.10.4 → 0.12.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 +128 -3
- data/README.md +45 -86
- data/SPEC.md +266 -138
- data/docs/conventions.md +47 -15
- data/lib/textus/application/reads/freshness.rb +2 -2
- data/lib/textus/application/reads/get.rb +1 -1
- data/lib/textus/application/reads/policy_explain.rb +2 -2
- data/lib/textus/application/refresh/orchestrator.rb +1 -1
- data/lib/textus/application/refresh/worker.rb +5 -5
- data/lib/textus/application/writes/accept.rb +19 -1
- data/lib/textus/application/writes/build.rb +5 -5
- data/lib/textus/application/writes/delete.rb +2 -3
- data/lib/textus/application/writes/publish.rb +1 -1
- data/lib/textus/application/writes/put.rb +3 -6
- data/lib/textus/builder/pipeline.rb +1 -1
- data/lib/textus/builder/renderer/json.rb +1 -1
- data/lib/textus/builder/renderer/yaml.rb +1 -1
- data/lib/textus/cli/group/key.rb +1 -1
- data/lib/textus/cli/group/refresh.rb +21 -0
- data/lib/textus/cli/group/rule.rb +11 -0
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -2
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +1 -1
- data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/cli.rb +6 -6
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +39 -16
- data/lib/textus/doctor/check/intake_registration.rb +4 -4
- data/lib/textus/doctor/check/protocol_version.rb +47 -0
- data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
- data/lib/textus/doctor.rb +5 -4
- data/lib/textus/domain/permission.rb +4 -4
- data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
- data/lib/textus/domain/policy/promotion.rb +45 -0
- data/lib/textus/errors.rb +24 -5
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/dispatcher.rb +1 -1
- data/lib/textus/hooks/dsl.rb +3 -10
- data/lib/textus/hooks/loader.rb +1 -2
- data/lib/textus/hooks/registry.rb +22 -21
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/init.rb +25 -34
- data/lib/textus/intro.rb +9 -9
- data/lib/textus/manifest/entry.rb +66 -6
- data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
- data/lib/textus/manifest/schema.rb +49 -0
- data/lib/textus/manifest.rb +79 -39
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +4 -4
- data/lib/textus/refresh.rb +1 -1
- data/lib/textus/store/mover.rb +91 -50
- data/lib/textus/store/staleness/generator_check.rb +88 -0
- data/lib/textus/store/staleness/intake_check.rb +46 -0
- data/lib/textus/store/staleness.rb +9 -104
- data/lib/textus/store/writer.rb +14 -12
- data/lib/textus/store.rb +1 -1
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +1 -0
- metadata +15 -7
- data/lib/textus/cli/group/policy.rb +0 -11
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
|
|
47
|
+
## Derived entries
|
|
48
48
|
|
|
49
|
-
|
|
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.
|
|
53
|
-
path: output/catalogs/
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
100
|
+
A typical scheduled-refresh integration shells the `refresh-stale` sweep itself:
|
|
65
101
|
|
|
66
102
|
```sh
|
|
67
|
-
textus
|
|
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
|
-
|
|
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
|
|
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
module Application
|
|
5
5
|
module Reads
|
|
6
6
|
# Per-entry freshness report. Walks every entry declared in the manifest,
|
|
7
|
-
# consults `
|
|
7
|
+
# consults `rules_for(key)` for a refresh rule, and reports the
|
|
8
8
|
# current status. Status is one of :fresh, :stale, :never_refreshed, or
|
|
9
9
|
# :no_policy.
|
|
10
10
|
class Freshness
|
|
@@ -27,7 +27,7 @@ module Textus
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
29
|
def row_for(mentry)
|
|
30
|
-
set = @ctx.store.manifest.
|
|
30
|
+
set = @ctx.store.manifest.rules_for(mentry.key)
|
|
31
31
|
refresh = set.refresh
|
|
32
32
|
envelope = safe_get(mentry.key)
|
|
33
33
|
last = envelope&.dig("_meta", "last_refreshed_at")
|
|
@@ -12,7 +12,7 @@ module Textus
|
|
|
12
12
|
envelope = @ctx.store.reader.read_raw_envelope(key)
|
|
13
13
|
return nil if envelope.nil?
|
|
14
14
|
|
|
15
|
-
policy_set = @ctx.store.manifest.
|
|
15
|
+
policy_set = @ctx.store.manifest.rules_for(key)
|
|
16
16
|
refresh_policy = policy_set.refresh
|
|
17
17
|
return annotate_fresh(envelope) if refresh_policy.nil?
|
|
18
18
|
|
|
@@ -9,7 +9,7 @@ module Textus
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def call(key:)
|
|
12
|
-
policies = @ctx.store.manifest.
|
|
12
|
+
policies = @ctx.store.manifest.rules
|
|
13
13
|
matching = policies.explain(key)
|
|
14
14
|
winners = policies.for(key)
|
|
15
15
|
|
|
@@ -29,7 +29,7 @@ module Textus
|
|
|
29
29
|
on_stale: winners.refresh.on_stale,
|
|
30
30
|
},
|
|
31
31
|
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
32
|
-
|
|
32
|
+
promotion: winners.promote && { requires: winners.promote.requires },
|
|
33
33
|
},
|
|
34
34
|
}
|
|
35
35
|
end
|
|
@@ -50,7 +50,7 @@ module Textus
|
|
|
50
50
|
store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
|
|
51
51
|
payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
|
|
52
52
|
payload[:store] = store_view if store_view
|
|
53
|
-
@bus.publish(:
|
|
53
|
+
@bus.publish(:refresh_backgrounded, **payload)
|
|
54
54
|
@detached_spawner.call(store_root: @store_root, key: key)
|
|
55
55
|
Textus::Domain::Outcome::Detached.new
|
|
56
56
|
elsif result.is_a?(Textus::Error)
|
|
@@ -27,9 +27,9 @@ module Textus
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def fetch_with_bus(key, mentry)
|
|
30
|
-
callable = @ctx.store.registry.rpc_callable(:
|
|
31
|
-
@bus.publish(:
|
|
32
|
-
|
|
30
|
+
callable = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
|
|
31
|
+
@bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
|
|
32
|
+
correlation_id: @ctx.correlation_id)
|
|
33
33
|
call_intake(key, mentry, callable)
|
|
34
34
|
end
|
|
35
35
|
|
|
@@ -61,8 +61,8 @@ module Textus
|
|
|
61
61
|
)
|
|
62
62
|
change = detect_change(before_etag, envelope)
|
|
63
63
|
unless change == :unchanged
|
|
64
|
-
@bus.publish(:
|
|
65
|
-
|
|
64
|
+
@bus.publish(:entry_refreshed, store: read_view, key: key, envelope: envelope, change: change,
|
|
65
|
+
correlation_id: @ctx.correlation_id)
|
|
66
66
|
end
|
|
67
67
|
envelope
|
|
68
68
|
end
|
|
@@ -15,6 +15,8 @@ module Textus
|
|
|
15
15
|
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
16
16
|
action = proposal["action"] || "put"
|
|
17
17
|
|
|
18
|
+
evaluate_promotion!(env, target)
|
|
19
|
+
|
|
18
20
|
case action
|
|
19
21
|
when "put"
|
|
20
22
|
# Nested proposal "frontmatter" — the meta to write to the accepted
|
|
@@ -30,7 +32,7 @@ module Textus
|
|
|
30
32
|
|
|
31
33
|
Composition.writes_delete(@ctx).call(pending_key)
|
|
32
34
|
|
|
33
|
-
@bus.publish(:
|
|
35
|
+
@bus.publish(:proposal_accepted,
|
|
34
36
|
store: @ctx.with_role(@ctx.role),
|
|
35
37
|
key: pending_key,
|
|
36
38
|
target_key: target,
|
|
@@ -38,6 +40,22 @@ module Textus
|
|
|
38
40
|
|
|
39
41
|
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
40
42
|
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def evaluate_promotion!(env, target_key)
|
|
47
|
+
rules = @ctx.store.manifest.rules_for(target_key)
|
|
48
|
+
promote = rules.promote
|
|
49
|
+
return if promote.nil? || promote.requires.empty?
|
|
50
|
+
|
|
51
|
+
policy = Textus::Domain::Policy::Promotion.from_names(promote.requires)
|
|
52
|
+
result = policy.evaluate(entry: env, store: @ctx.store)
|
|
53
|
+
return if result.ok?
|
|
54
|
+
|
|
55
|
+
raise ProposalError.new(
|
|
56
|
+
"promotion gate failed: #{result.reasons.join("; ")}",
|
|
57
|
+
)
|
|
58
|
+
end
|
|
41
59
|
end
|
|
42
60
|
end
|
|
43
61
|
end
|
|
@@ -5,8 +5,8 @@ module Textus
|
|
|
5
5
|
module Writes
|
|
6
6
|
# Materializes generator-zone entries (template + projection) onto disk
|
|
7
7
|
# and copies the result to any configured `publish_to` / `publish_each`
|
|
8
|
-
# targets. Fires `:
|
|
9
|
-
# the request's correlation_id for traceability.
|
|
8
|
+
# targets. Fires `:build_completed` and `:file_published` events on the bus,
|
|
9
|
+
# tagged with the request's correlation_id for traceability.
|
|
10
10
|
class Build
|
|
11
11
|
def initialize(ctx:, bus:)
|
|
12
12
|
@ctx = ctx
|
|
@@ -59,7 +59,7 @@ module Textus
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: root)
|
|
62
|
-
publish_event(:
|
|
62
|
+
publish_event(:file_published,
|
|
63
63
|
key: row[:key],
|
|
64
64
|
envelope: store.get(row[:key]),
|
|
65
65
|
source: row[:path],
|
|
@@ -91,14 +91,14 @@ module Textus
|
|
|
91
91
|
mentry.publish_to.each do |rel|
|
|
92
92
|
target_abs = File.join(repo_root, rel)
|
|
93
93
|
Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: root)
|
|
94
|
-
publish_event(:
|
|
94
|
+
publish_event(:file_published,
|
|
95
95
|
key: mentry.key,
|
|
96
96
|
envelope: envelope,
|
|
97
97
|
source: target_path,
|
|
98
98
|
target: target_abs)
|
|
99
99
|
end
|
|
100
100
|
|
|
101
|
-
publish_event(:
|
|
101
|
+
publish_event(:build_completed,
|
|
102
102
|
key: mentry.key,
|
|
103
103
|
envelope: envelope,
|
|
104
104
|
sources: Array(mentry.projection&.fetch("select", nil)).compact)
|
|
@@ -17,12 +17,11 @@ module Textus
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
@ctx.store.writer.delete_envelope_from_disk(
|
|
20
|
-
key,
|
|
21
|
-
correlation_id: @ctx.correlation_id
|
|
20
|
+
key, ctx: @ctx, if_etag: if_etag
|
|
22
21
|
)
|
|
23
22
|
|
|
24
23
|
unless suppress_events
|
|
25
|
-
@bus.publish(:
|
|
24
|
+
@bus.publish(:entry_deleted,
|
|
26
25
|
store: @ctx.with_role(@ctx.role),
|
|
27
26
|
key: key,
|
|
28
27
|
correlation_id: @ctx.correlation_id)
|
|
@@ -19,16 +19,13 @@ module Textus
|
|
|
19
19
|
envelope = @ctx.store.writer.write_envelope_to_disk(
|
|
20
20
|
key,
|
|
21
21
|
mentry: mentry,
|
|
22
|
-
meta: meta,
|
|
23
|
-
|
|
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
|
|
31
|
-
@bus.publish(:
|
|
28
|
+
@bus.publish(:entry_put,
|
|
32
29
|
store: @ctx.with_role(@ctx.role),
|
|
33
30
|
key: key,
|
|
34
31
|
envelope: envelope,
|
|
@@ -10,7 +10,7 @@ module Textus
|
|
|
10
10
|
from = Array(mentry.projection&.fetch("select", nil)).compact
|
|
11
11
|
meta["from"] = from unless from.empty?
|
|
12
12
|
meta["template"] = mentry.template if mentry.template
|
|
13
|
-
reduce = mentry.projection&.dig("
|
|
13
|
+
reduce = mentry.projection&.dig("transform")
|
|
14
14
|
meta["reduce"] = reduce if reduce
|
|
15
15
|
|
|
16
16
|
out = { "_meta" => meta }
|
|
@@ -28,7 +28,7 @@ module Textus
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def default_shape(mentry, data)
|
|
31
|
-
if mentry.projection && mentry.projection["
|
|
31
|
+
if mentry.projection && mentry.projection["transform"] && data.is_a?(Hash) && !data.key?("entries")
|
|
32
32
|
data
|
|
33
33
|
elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
|
|
34
34
|
{ "entries" => data["entries"] }
|
|
@@ -28,7 +28,7 @@ module Textus
|
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def default_shape(mentry, data)
|
|
31
|
-
if mentry.projection && mentry.projection["
|
|
31
|
+
if mentry.projection && mentry.projection["transform"] && data.is_a?(Hash) && !data.key?("entries")
|
|
32
32
|
data
|
|
33
33
|
elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
|
|
34
34
|
{ "entries" => data["entries"] }
|
data/lib/textus/cli/group/key.rb
CHANGED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Group
|
|
4
|
+
class Refresh < Group
|
|
5
|
+
self.cli_name = "refresh"
|
|
6
|
+
subcommands["stale"] = Verb::RefreshStale
|
|
7
|
+
|
|
8
|
+
def parse(argv)
|
|
9
|
+
if argv.first == "stale"
|
|
10
|
+
argv.shift
|
|
11
|
+
@sub_klass = Verb::RefreshStale
|
|
12
|
+
else
|
|
13
|
+
@sub_klass = Verb::Refresh
|
|
14
|
+
end
|
|
15
|
+
@sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
|
|
16
|
+
@sub.parse(argv)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -15,7 +15,8 @@ module Textus
|
|
|
15
15
|
@raw_argv.each do |tok|
|
|
16
16
|
case tok
|
|
17
17
|
when /\A--as=(.+)\z/ then as_flag = ::Regexp.last_match(1)
|
|
18
|
-
when /\A--
|
|
18
|
+
when /\A--output=/ then next
|
|
19
|
+
when /\A--format=/ then raise FlagRenamed.new("--format", "--output")
|
|
19
20
|
when /\A--([\w-]+)=(.*)\z/ then args[::Regexp.last_match(1)] = ::Regexp.last_match(2)
|
|
20
21
|
else
|
|
21
22
|
raise UsageError.new("unknown arg to 'hook run #{name}': #{tok}")
|
|
@@ -23,7 +24,7 @@ module Textus
|
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
26
|
-
callable = store.registry.rpc_callable(:
|
|
27
|
+
callable = store.registry.rpc_callable(:resolve_intake, name)
|
|
27
28
|
view = Application::Context.new(store: store, role: role)
|
|
28
29
|
|
|
29
30
|
begin
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
raw = @stdin.read
|
|
16
16
|
payload =
|
|
17
17
|
if fetch_name
|
|
18
|
-
callable = store.registry.rpc_callable(:
|
|
18
|
+
callable = store.registry.rpc_callable(:resolve_intake, fetch_name)
|
|
19
19
|
result =
|
|
20
20
|
begin
|
|
21
21
|
Timeout.timeout(Textus::Application::Refresh::Worker::FETCH_TIMEOUT_SECONDS) do
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class
|
|
4
|
+
class RuleList < Verb
|
|
5
5
|
def call(store)
|
|
6
|
-
policies = store.manifest.
|
|
6
|
+
policies = store.manifest.rules.blocks.map do |b|
|
|
7
7
|
row = { "match" => b.match }
|
|
8
8
|
if b.refresh
|
|
9
9
|
row["refresh"] = {
|
|
@@ -13,7 +13,7 @@ module Textus
|
|
|
13
13
|
}
|
|
14
14
|
end
|
|
15
15
|
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
16
|
-
row["
|
|
16
|
+
row["promotion"] = { "requires" => b.promote.requires } if b.promote
|
|
17
17
|
row["retention"] = b.retention if b.retention
|
|
18
18
|
row
|
|
19
19
|
end
|
data/lib/textus/cli/verb.rb
CHANGED
|
@@ -41,9 +41,10 @@ module Textus
|
|
|
41
41
|
self.class.options.each do |name, optspec|
|
|
42
42
|
o.on(optspec) { |v| public_send(:"#{name}=", v) }
|
|
43
43
|
end
|
|
44
|
-
o.on("--
|
|
44
|
+
o.on("--output=FMT") { |v| fmt = v }
|
|
45
|
+
o.on("--format=FMT") { |_v| raise FlagRenamed.new("--format", "--output") }
|
|
45
46
|
end.permute!(argv)
|
|
46
|
-
raise UsageError.new("only --
|
|
47
|
+
raise UsageError.new("only --output=json is supported in v1") unless fmt == "json"
|
|
47
48
|
|
|
48
49
|
@positional = argv.dup
|
|
49
50
|
end
|
data/lib/textus/cli.rb
CHANGED
|
@@ -21,12 +21,11 @@ module Textus
|
|
|
21
21
|
"intro" => Verb::Intro,
|
|
22
22
|
"key" => Group::Key,
|
|
23
23
|
"list" => Verb::List,
|
|
24
|
-
"policy" => Group::Policy,
|
|
25
24
|
"published" => Verb::Published,
|
|
26
25
|
"put" => Verb::Put,
|
|
27
26
|
"rdeps" => Verb::Rdeps,
|
|
28
|
-
"refresh" =>
|
|
29
|
-
"
|
|
27
|
+
"refresh" => Group::Refresh,
|
|
28
|
+
"rule" => Group::Rule,
|
|
30
29
|
"schema" => Group::Schema,
|
|
31
30
|
"where" => Verb::Where,
|
|
32
31
|
}.freeze
|
|
@@ -90,16 +89,17 @@ module Textus
|
|
|
90
89
|
textus get KEY
|
|
91
90
|
textus put KEY --stdin [--fetch=NAME] --as=ROLE
|
|
92
91
|
textus freshness [--prefix=KEY] [--zone=Z]
|
|
93
|
-
textus refresh
|
|
92
|
+
textus refresh KEY
|
|
93
|
+
textus refresh stale [--prefix=KEY] [--zone=Z]
|
|
94
94
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
95
95
|
textus blame KEY [--limit=N]
|
|
96
96
|
textus doctor
|
|
97
97
|
textus intro
|
|
98
98
|
|
|
99
|
-
textus key {mv,uid,
|
|
99
|
+
textus key {mv,uid,normalize}
|
|
100
|
+
textus rule {list,explain}
|
|
100
101
|
textus schema {show,init,diff,migrate}
|
|
101
102
|
textus hook {list,run}
|
|
102
|
-
textus policy {list,explain}
|
|
103
103
|
HELP
|
|
104
104
|
end
|
|
105
105
|
end
|
|
@@ -11,7 +11,7 @@ module Textus
|
|
|
11
11
|
handler = mentry.intake_handler
|
|
12
12
|
next if handler.nil?
|
|
13
13
|
|
|
14
|
-
allow = store.manifest.
|
|
14
|
+
allow = store.manifest.rules_for(mentry.key).handler_allowlist
|
|
15
15
|
next if allow.nil?
|
|
16
16
|
next if allow.allows?(handler)
|
|
17
17
|
|
|
@@ -10,28 +10,51 @@ module Textus
|
|
|
10
10
|
base = File.join(store.root, "zones", entry.path)
|
|
11
11
|
next unless File.directory?(base)
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
basename = File.basename(abs_path)
|
|
15
|
-
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
16
|
-
next if stem.match?(Key::Grammar::SEGMENT)
|
|
17
|
-
|
|
18
|
-
proposed = Textus::MigrateKeys.normalize(stem)
|
|
19
|
-
out << {
|
|
20
|
-
"code" => "key.illegal",
|
|
21
|
-
"level" => "error",
|
|
22
|
-
"subject" => abs_path,
|
|
23
|
-
"path" => abs_path,
|
|
24
|
-
"proposed_key" => proposed,
|
|
25
|
-
"message" => "illegal key segment '#{stem}' at #{abs_path}",
|
|
26
|
-
"fix" => "run 'textus key migrate --dry-run' then '--write' to rename to '#{proposed}'",
|
|
27
|
-
}
|
|
28
|
-
end
|
|
13
|
+
entry.index_filename ? check_index_paths(entry, base, out) : check_all_paths(base, out)
|
|
29
14
|
end
|
|
30
15
|
out
|
|
31
16
|
end
|
|
32
17
|
|
|
33
18
|
private
|
|
34
19
|
|
|
20
|
+
def check_all_paths(base, out)
|
|
21
|
+
walk_nested(base) do |abs_path, is_dir|
|
|
22
|
+
basename = File.basename(abs_path)
|
|
23
|
+
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
24
|
+
next if stem.match?(Key::Grammar::SEGMENT)
|
|
25
|
+
|
|
26
|
+
out << issue(abs_path, stem)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# When the entry uses `index_filename:`, only the parent-directory
|
|
31
|
+
# segments leading to each index file participate in keys. Sibling
|
|
32
|
+
# files and unrelated subtrees are not enumerated and must not be
|
|
33
|
+
# flagged. Each illegal segment is reported once per path.
|
|
34
|
+
def check_index_paths(entry, base, out)
|
|
35
|
+
Dir.glob(File.join(base, "**", entry.index_filename)).each do |fp|
|
|
36
|
+
rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
37
|
+
File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
|
|
38
|
+
next if seg.match?(Key::Grammar::SEGMENT)
|
|
39
|
+
|
|
40
|
+
out << issue(fp, seg)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def issue(abs_path, stem)
|
|
46
|
+
proposed = Textus::MigrateKeys.normalize(stem)
|
|
47
|
+
{
|
|
48
|
+
"code" => "key.illegal",
|
|
49
|
+
"level" => "error",
|
|
50
|
+
"subject" => abs_path,
|
|
51
|
+
"path" => abs_path,
|
|
52
|
+
"proposed_key" => proposed,
|
|
53
|
+
"message" => "illegal key segment '#{stem}' at #{abs_path}",
|
|
54
|
+
"fix" => "run 'textus key normalize --dry-run' then '--write' to rename to '#{proposed}'",
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
35
58
|
def walk_nested(root, &block)
|
|
36
59
|
Dir.each_child(root) do |name|
|
|
37
60
|
abs = File.join(root, name)
|
|
@@ -6,15 +6,15 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
def call
|
|
8
8
|
declared = collect_declared_handlers
|
|
9
|
-
registered = store.registry.rpc_names(:
|
|
9
|
+
registered = store.registry.rpc_names(:resolve_intake).to_set
|
|
10
10
|
|
|
11
11
|
out = (declared - registered).map do |name|
|
|
12
12
|
{
|
|
13
13
|
"code" => "intake.handler_missing",
|
|
14
14
|
"level" => "error",
|
|
15
15
|
"subject" => name.to_s,
|
|
16
|
-
"message" => "manifest references intake handler '#{name}' but no Textus.
|
|
17
|
-
"fix" => "create .textus/hooks/#{name}.rb with `Textus.
|
|
16
|
+
"message" => "manifest references intake handler '#{name}' but no Textus.on(:resolve_intake, :#{name}) is registered",
|
|
17
|
+
"fix" => "create .textus/hooks/#{name}.rb with `Textus.on(:resolve_intake, :#{name}) { ... }`",
|
|
18
18
|
}
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -23,7 +23,7 @@ module Textus
|
|
|
23
23
|
"code" => "intake.handler_orphan",
|
|
24
24
|
"level" => "warning",
|
|
25
25
|
"subject" => name.to_s,
|
|
26
|
-
"message" => "Textus.
|
|
26
|
+
"message" => "Textus.on(:resolve_intake, :#{name}) is registered but no manifest entry references it",
|
|
27
27
|
"fix" => "remove the unused handler, or add an entry with `intake.handler: #{name}`",
|
|
28
28
|
}
|
|
29
29
|
end
|