textus 0.4.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +147 -2
  3. data/README.md +38 -28
  4. data/SPEC.md +84 -147
  5. data/docs/architecture.md +82 -28
  6. data/lib/textus/builder/pipeline.rb +56 -0
  7. data/lib/textus/builder/renderer/json.rb +42 -0
  8. data/lib/textus/builder/renderer/markdown.rb +22 -0
  9. data/lib/textus/builder/renderer/text.rb +14 -0
  10. data/lib/textus/builder/renderer/yaml.rb +42 -0
  11. data/lib/textus/builder/renderer.rb +17 -0
  12. data/lib/textus/builder.rb +9 -114
  13. data/lib/textus/cli/group/hook.rb +11 -0
  14. data/lib/textus/cli/group/key.rb +12 -0
  15. data/lib/textus/cli/group/schema.rb +13 -0
  16. data/lib/textus/cli/group.rb +51 -0
  17. data/lib/textus/cli/verb/accept.rb +15 -0
  18. data/lib/textus/cli/verb/build.rb +13 -0
  19. data/lib/textus/cli/verb/delete.rb +16 -0
  20. data/lib/textus/cli/verb/deps.rb +12 -0
  21. data/lib/textus/cli/verb/doctor.rb +15 -0
  22. data/lib/textus/cli/verb/get.rb +12 -0
  23. data/lib/textus/cli/verb/hook_run.rb +48 -0
  24. data/lib/textus/cli/verb/hooks.rb +50 -0
  25. data/lib/textus/cli/verb/init.rb +14 -0
  26. data/lib/textus/cli/verb/intro.rb +11 -0
  27. data/lib/textus/cli/verb/list.rb +14 -0
  28. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  29. data/lib/textus/cli/verb/mv.rb +17 -0
  30. data/lib/textus/cli/verb/published.rb +11 -0
  31. data/lib/textus/cli/verb/put.rb +50 -0
  32. data/lib/textus/cli/verb/rdeps.rb +12 -0
  33. data/lib/textus/cli/verb/refresh.rb +15 -0
  34. data/lib/textus/cli/verb/schema.rb +12 -0
  35. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  36. data/lib/textus/cli/verb/schema_init.rb +16 -0
  37. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  38. data/lib/textus/cli/verb/stale.rb +14 -0
  39. data/lib/textus/cli/verb/uid.rb +12 -0
  40. data/lib/textus/cli/verb/where.rb +12 -0
  41. data/lib/textus/cli/verb.rb +62 -0
  42. data/lib/textus/cli.rb +44 -385
  43. data/lib/textus/doctor/check/audit_log.rb +50 -0
  44. data/lib/textus/doctor/check/hooks.rb +29 -0
  45. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  46. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  47. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  48. data/lib/textus/doctor/check/schemas.rb +26 -0
  49. data/lib/textus/doctor/check/sentinels.rb +57 -0
  50. data/lib/textus/doctor/check/templates.rb +26 -0
  51. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  52. data/lib/textus/doctor/check.rb +30 -0
  53. data/lib/textus/doctor.rb +29 -264
  54. data/lib/textus/entry/base.rb +30 -0
  55. data/lib/textus/entry/json.rb +11 -5
  56. data/lib/textus/entry/markdown.rb +5 -5
  57. data/lib/textus/entry/text.rb +4 -4
  58. data/lib/textus/entry/yaml.rb +11 -5
  59. data/lib/textus/entry.rb +2 -7
  60. data/lib/textus/envelope.rb +30 -0
  61. data/lib/textus/errors.rb +2 -2
  62. data/lib/textus/hooks/builtin.rb +70 -0
  63. data/lib/textus/hooks/dispatcher.rb +49 -0
  64. data/lib/textus/hooks/loader.rb +26 -0
  65. data/lib/textus/hooks/registry.rb +73 -0
  66. data/lib/textus/init.rb +14 -11
  67. data/lib/textus/intro.rb +16 -18
  68. data/lib/textus/key/distance.rb +55 -0
  69. data/lib/textus/key/grammar.rb +33 -0
  70. data/lib/textus/key/path.rb +17 -0
  71. data/lib/textus/manifest/entry.rb +199 -0
  72. data/lib/textus/manifest.rb +20 -254
  73. data/lib/textus/migrate_keys.rb +1 -1
  74. data/lib/textus/projection.rb +6 -5
  75. data/lib/textus/proposal.rb +4 -4
  76. data/lib/textus/refresh.rb +17 -17
  77. data/lib/textus/schema/tools.rb +89 -0
  78. data/lib/textus/store/audit_log.rb +71 -0
  79. data/lib/textus/store/mover.rb +121 -0
  80. data/lib/textus/store/reader.rb +67 -0
  81. data/lib/textus/store/staleness.rb +133 -0
  82. data/lib/textus/store/validator.rb +56 -0
  83. data/lib/textus/store/view.rb +29 -0
  84. data/lib/textus/store/writer.rb +132 -0
  85. data/lib/textus/store.rb +26 -527
  86. data/lib/textus/version.rb +2 -2
  87. data/lib/textus.rb +14 -29
  88. metadata +78 -8
  89. data/lib/textus/audit_log.rb +0 -32
  90. data/lib/textus/builtin_actions.rb +0 -68
  91. data/lib/textus/extension_registry.rb +0 -61
  92. data/lib/textus/extensions.rb +0 -33
  93. data/lib/textus/key_distance.rb +0 -53
  94. data/lib/textus/schema_tools.rb +0 -87
  95. data/lib/textus/store_view.rb +0 -27
data/lib/textus/cli.rb CHANGED
@@ -1,12 +1,31 @@
1
1
  require "json"
2
2
  require "optparse"
3
- require "time"
4
- require "timeout"
5
- require "yaml"
6
3
 
7
4
  module Textus
8
- # rubocop:disable Metrics/ClassLength
9
5
  class CLI
6
+ # verb name → Verb subclass. Adding a new verb is a one-line entry here
7
+ # plus a new file under lib/textus/cli/.
8
+ VERBS = {
9
+ "accept" => Verb::Accept,
10
+ "build" => Verb::Build,
11
+ "delete" => Verb::Delete,
12
+ "deps" => Verb::Deps,
13
+ "doctor" => Verb::Doctor,
14
+ "get" => Verb::Get,
15
+ "hook" => Group::Hook,
16
+ "init" => Verb::Init,
17
+ "intro" => Verb::Intro,
18
+ "key" => Group::Key,
19
+ "list" => Verb::List,
20
+ "published" => Verb::Published,
21
+ "put" => Verb::Put,
22
+ "rdeps" => Verb::Rdeps,
23
+ "refresh" => Verb::Refresh,
24
+ "schema" => Group::Schema,
25
+ "stale" => Verb::Stale,
26
+ "where" => Verb::Where,
27
+ }.freeze
28
+
10
29
  def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
11
30
  new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
12
31
  end
@@ -19,44 +38,19 @@ module Textus
19
38
  @root_arg = nil
20
39
  end
21
40
 
22
- def run(argv) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
23
- OptionParser.new do |o|
24
- o.on("--root=PATH") { |v| @root_arg = v }
25
- end.order!(argv)
41
+ def run(argv)
42
+ OptionParser.new { |o| o.on("--root=PATH") { |v| @root_arg = v } }.order!(argv)
26
43
  verb = argv.shift
27
44
  raise UsageError.new("missing verb") if verb.nil?
28
45
 
29
46
  case verb
30
- when "list" then verb_list(argv)
31
- when "where" then verb_where(argv)
32
- when "get" then verb_get(argv)
33
- when "put" then verb_put(argv)
34
- when "schema" then verb_schema(argv)
35
- when "stale" then verb_stale(argv)
36
- when "delete" then verb_delete(argv)
37
- when "validate-all" then verb_validate_all(argv)
38
- when "build" then verb_build(argv)
39
- when "deps" then verb_deps(argv)
40
- when "rdeps" then verb_rdeps(argv)
41
- when "published" then verb_published(argv)
42
- when "accept" then verb_accept(argv)
43
- when "init" then verb_init(argv)
44
- when "schema-init" then verb_schema_init(argv)
45
- when "schema-diff" then verb_schema_diff(argv)
46
- when "schema-migrate" then verb_schema_migrate(argv)
47
- when "action" then verb_action(argv)
48
- when "refresh" then verb_refresh(argv)
49
- when "extensions" then verb_extensions(argv)
50
- when "migrate-keys" then verb_migrate_keys(argv)
51
- when "mv" then verb_mv(argv)
52
- when "uid" then verb_uid(argv)
53
- when "doctor" then verb_doctor(argv)
54
- when "intro" then verb_intro(argv)
55
47
  when "--version", "-v" then @stdout.puts(VERSION)
56
48
  0
57
49
  when "--help", "-h" then print_help
58
50
  0
59
- else raise UsageError.new("unknown verb: #{verb}")
51
+ else
52
+ klass = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
53
+ dispatch(klass, argv)
60
54
  end
61
55
  rescue Textus::Error => e
62
56
  emit_error(e)
@@ -68,348 +62,10 @@ module Textus
68
62
  @store ||= Store.discover(@cwd, root: @root_arg)
69
63
  end
70
64
 
71
- def parse_format!(argv)
72
- fmt = "text"
73
- OptionParser.new do |o|
74
- o.on("--format=FMT") { |v| fmt = v }
75
- end.permute!(argv)
76
- raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
77
-
78
- fmt
79
- end
80
-
81
- def parse_prefix!(argv)
82
- prefix = nil
83
- OptionParser.new do |o|
84
- o.on("--prefix=KEY") { |v| prefix = v }
85
- o.on("--zone=Z") {}
86
- o.on("--format=FMT") {}
87
- end.permute!(argv)
88
- prefix
89
- end
90
-
91
- def parse_prefix_and_zone!(argv)
92
- prefix = nil
93
- zone = nil
94
- fmt = "text"
95
- OptionParser.new do |o|
96
- o.on("--prefix=KEY") { |v| prefix = v }
97
- o.on("--zone=Z") { |v| zone = v }
98
- o.on("--format=FMT") { |v| fmt = v }
99
- end.permute!(argv)
100
- raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
101
-
102
- [prefix, zone]
103
- end
104
-
105
- def verb_list(argv)
106
- prefix, zone = parse_prefix_and_zone!(argv)
107
- emit({ "protocol" => PROTOCOL, "entries" => store.list(prefix: prefix, zone: zone) })
108
- end
109
-
110
- def verb_where(argv)
111
- key = argv.shift or raise UsageError.new("where requires a key")
112
- parse_format!(argv)
113
- emit(store.where(key))
114
- end
115
-
116
- def verb_get(argv)
117
- key = argv.shift or raise UsageError.new("get requires a key")
118
- parse_format!(argv)
119
- emit(store.get(key))
120
- end
121
-
122
- def verb_schema(argv)
123
- key = argv.shift or raise UsageError.new("schema requires a key")
124
- parse_format!(argv)
125
- emit(store.schema_envelope(key))
126
- end
127
-
128
- def verb_stale(argv)
129
- prefix, zone = parse_prefix_and_zone!(argv)
130
- emit(store.stale(prefix: prefix, zone: zone))
131
- end
132
-
133
- def verb_put(argv) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
134
- key = argv.shift or raise UsageError.new("put requires a key")
135
- as_flag = nil
136
- use_stdin = false
137
- action_name = nil
138
- OptionParser.new do |o|
139
- o.on("--stdin") { use_stdin = true }
140
- o.on("--as=ROLE") { |v| as_flag = v }
141
- o.on("--action=NAME") { |v| action_name = v }
142
- o.on("--format=FMT") {}
143
- end.permute!(argv)
144
- raise UsageError.new("put requires --stdin in v1") unless use_stdin
145
-
146
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
147
-
148
- raw = @stdin.read
149
- payload =
150
- if action_name
151
- callable = store.registry.action(action_name)
152
- result =
153
- begin
154
- Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
155
- callable.call(config: { "bytes" => raw }, store: Textus::StoreView.new(store), args: {})
156
- end
157
- rescue Timeout::Error
158
- raise UsageError.new(
159
- "action '#{action_name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
160
- )
161
- end
162
- basename = key.split(".").last
163
- {
164
- "frontmatter" => {
165
- "name" => basename,
166
- "last_refreshed_at" => Time.now.utc.iso8601,
167
- "actioned_with" => action_name,
168
- }.merge(result[:frontmatter] || result["frontmatter"] || {}),
169
- "body" => result[:body] || result["body"] || "",
170
- }
171
- else
172
- JSON.parse(raw)
173
- end
174
-
175
- fm = payload["frontmatter"] || {}
176
- body = payload["body"] || ""
177
- if_etag = payload["if_etag"]
178
- emit(store.put(key, frontmatter: fm, body: body, if_etag: if_etag, as: role))
179
- end
180
-
181
- def verb_delete(argv)
182
- key = argv.shift or raise UsageError.new("delete requires a key")
183
- as_flag = nil
184
- if_etag = nil
185
- OptionParser.new do |o|
186
- o.on("--as=ROLE") { |v| as_flag = v }
187
- o.on("--if-etag=E") { |v| if_etag = v }
188
- o.on("--format=FMT") {}
189
- end.permute!(argv)
190
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
191
- emit(store.delete(key, if_etag: if_etag, as: role))
192
- end
193
-
194
- def verb_validate_all(argv)
195
- parse_format!(argv)
196
- res = store.validate_all
197
- @stdout.puts(JSON.generate(res))
198
- res["ok"] ? 0 : 1
199
- end
200
-
201
- def verb_build(argv)
202
- prefix = nil
203
- OptionParser.new do |o|
204
- o.on("--prefix=K") { |v| prefix = v }
205
- o.on("--format=FMT") {}
206
- end.permute!(argv)
207
- res = Textus::Builder.new(store).build(prefix: prefix)
208
- @stdout.puts(JSON.generate(res))
209
- 0
210
- end
211
-
212
- def verb_deps(argv)
213
- key = argv.shift or raise UsageError.new("deps requires a key")
214
- parse_format!(argv)
215
- emit({ "protocol" => Textus::PROTOCOL, "key" => key, "deps" => store.deps(key) })
216
- end
217
-
218
- def verb_rdeps(argv)
219
- key = argv.shift or raise UsageError.new("rdeps requires a key")
220
- parse_format!(argv)
221
- emit({ "protocol" => Textus::PROTOCOL, "key" => key, "rdeps" => store.rdeps(key) })
222
- end
223
-
224
- def verb_schema_init(argv)
225
- name = argv.shift or raise UsageError.new("schema-init NAME")
226
- from_key = nil
227
- OptionParser.new do |o|
228
- o.on("--from=KEY") { |v| from_key = v }
229
- o.on("--format=FMT") {}
230
- end.permute!(argv)
231
- raise UsageError.new("schema-init requires --from=KEY") unless from_key
232
-
233
- emit(Textus::SchemaTools.init(store, name: name, from: from_key))
234
- end
235
-
236
- def verb_schema_diff(argv)
237
- name = argv.shift or raise UsageError.new("schema-diff NAME")
238
- parse_format!(argv)
239
- emit(Textus::SchemaTools.diff(store, name: name))
240
- end
241
-
242
- def verb_schema_migrate(argv)
243
- name = argv.shift or raise UsageError.new("schema-migrate NAME")
244
- rename = nil
245
- OptionParser.new do |o|
246
- o.on("--rename=O:N") { |v| rename = v }
247
- o.on("--format=FMT") {}
248
- end.permute!(argv)
249
- raise UsageError.new("schema-migrate requires --rename=OLD:NEW") unless rename
250
-
251
- emit(Textus::SchemaTools.migrate(store, name: name, rename: rename))
252
- end
253
-
254
- def verb_init(argv)
255
- OptionParser.new do |o|
256
- o.on("--format=FMT") {}
257
- end.permute!(argv)
258
- target = File.join(@cwd, ".textus")
259
- res = Textus::Init.run(target)
260
- @stdout.puts(JSON.generate(res))
261
- 0
262
- end
263
-
264
- def verb_accept(argv)
265
- key = argv.shift or raise UsageError.new("accept requires a key")
266
- as_flag = nil
267
- OptionParser.new do |o|
268
- o.on("--as=ROLE") { |v| as_flag = v }
269
- o.on("--format=FMT") {}
270
- end.permute!(argv)
271
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
272
- emit(store.accept(key, as: role))
273
- end
274
-
275
- def verb_action(argv)
276
- name = argv.shift
277
- raise UsageError.new("action requires a name") if name.nil?
278
-
279
- as_flag = nil
280
- args = {}
281
- argv.each do |tok|
282
- case tok
283
- when /\A--as=(.+)\z/ then as_flag = ::Regexp.last_match(1)
284
- when /\A--format=/ then next
285
- when /\A--([\w-]+)=(.*)\z/ then args[::Regexp.last_match(1)] = ::Regexp.last_match(2)
286
- else
287
- raise UsageError.new("unknown arg to 'action #{name}': #{tok}")
288
- end
289
- end
290
-
291
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
292
- callable = store.registry.action(name)
293
- view = StoreView.new(store, writable: true, as: role)
294
-
295
- begin
296
- Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
297
- callable.call(config: {}, store: view, args: args)
298
- end
299
- rescue Timeout::Error
300
- raise UsageError.new(
301
- "action '#{name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
302
- )
303
- rescue Textus::Error
304
- raise
305
- rescue StandardError => e
306
- raise UsageError.new("action '#{name}' raised: #{e.class}: #{e.message}")
307
- end
308
-
309
- emit({ "protocol" => Textus::PROTOCOL, "action" => name, "ok" => true })
310
- end
311
-
312
- def verb_refresh(argv)
313
- key = argv.shift or raise UsageError.new("refresh requires a key")
314
- as_flag = nil
315
- OptionParser.new do |o|
316
- o.on("--as=ROLE") { |v| as_flag = v }
317
- o.on("--format=FMT") {}
318
- end.permute!(argv)
319
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
320
- emit(Textus::Refresh.call(store, key, as: role))
321
- end
322
-
323
- def verb_extensions(argv) # rubocop:disable Metrics/AbcSize
324
- subcommand = argv.shift
325
- raise UsageError.new("extensions requires 'list'") unless subcommand == "list"
326
-
327
- kind = nil
328
- OptionParser.new do |o|
329
- o.on("--kind=K") { |v| kind = v }
330
- o.on("--format=FMT") {}
331
- end.permute!(argv)
332
-
333
- rows = []
334
- rows += store.registry.action_names.map { |n| { "kind" => "action", "name" => n.to_s } }
335
- rows += store.registry.doctor_check_names.map { |n| { "kind" => "doctor_check", "name" => n.to_s } }
336
- rows += store.registry.reducer_names.map { |n| { "kind" => "reducer", "name" => n.to_s } }
337
- store.registry.hook_events.each do |evt|
338
- store.registry.hooks(evt).each do |h|
339
- rows << { "kind" => "hook", "event" => evt.to_s, "name" => h[:name].to_s }
340
- end
341
- end
342
- store.manifest.entries.each do |e|
343
- e.events.each do |evt, defs|
344
- Array(defs).each do |defn|
345
- next unless defn["exec"]
346
-
347
- rows << {
348
- "kind" => "hook", "event" => evt.to_s, "exec" => defn["exec"],
349
- "key" => e.key, "as" => defn["as"] || "script"
350
- }
351
- end
352
- end
353
- end
354
- rows.select! { |r| r["kind"] == kind } if kind
355
-
356
- emit({ "protocol" => Textus::PROTOCOL, "extensions" => rows })
357
- end
358
-
359
- def verb_migrate_keys(argv)
360
- write = false
361
- OptionParser.new do |o|
362
- o.on("--dry-run") { write = false }
363
- o.on("--write") { write = true }
364
- o.on("--format=FMT") {}
365
- end.permute!(argv)
366
- res = Textus::MigrateKeys.run(store, write: write)
367
- @stdout.puts(JSON.generate(res))
368
- res["ok"] ? 0 : 1
369
- end
370
-
371
- def verb_mv(argv)
372
- old_key = argv.shift or raise UsageError.new("mv requires <old-key> <new-key>")
373
- new_key = argv.shift or raise UsageError.new("mv requires <old-key> <new-key>")
374
- as_flag = nil
375
- dry_run = false
376
- OptionParser.new do |o|
377
- o.on("--as=ROLE") { |v| as_flag = v }
378
- o.on("--dry-run") { dry_run = true }
379
- o.on("--format=FMT") {}
380
- end.permute!(argv)
381
- role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
382
- emit(store.mv(old_key, new_key, as: role, dry_run: dry_run))
383
- end
384
-
385
- def verb_uid(argv)
386
- key = argv.shift or raise UsageError.new("uid requires a key")
387
- parse_format!(argv)
388
- emit({ "protocol" => PROTOCOL, "key" => key, "uid" => store.uid(key) })
389
- end
390
-
391
- def verb_intro(argv)
392
- parse_format!(argv)
393
- emit(Textus::Intro.run(store))
394
- end
395
-
396
- def verb_doctor(argv)
397
- OptionParser.new do |o|
398
- o.on("--format=FMT") {}
399
- end.permute!(argv)
400
- res = Textus::Doctor.run(store)
401
- @stdout.puts(JSON.generate(res))
402
- res["ok"] ? 0 : 1
403
- end
404
-
405
- def verb_published(argv)
406
- parse_format!(argv)
407
- emit({ "protocol" => Textus::PROTOCOL, "published" => store.published })
408
- end
409
-
410
- def emit(obj)
411
- @stdout.puts(JSON.generate(obj))
412
- 0
65
+ def dispatch(klass, argv)
66
+ v = klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
67
+ v.parse(argv)
68
+ v.call(klass.needs_store? ? store : nil)
413
69
  end
414
70
 
415
71
  def emit_error(err)
@@ -423,16 +79,19 @@ module Textus
423
79
  @stdout.puts <<~HELP
424
80
  textus #{VERSION} — reference implementation of #{PROTOCOL}
425
81
 
426
- Usage:
427
- textus list [--prefix=KEY] --format=json
428
- textus where KEY --format=json
429
- textus get KEY --format=json
430
- textus put KEY --stdin [--action=NAME] --format=json
431
- textus schema KEY --format=json
432
- textus stale [--prefix=KEY] --format=json
433
- textus action NAME [--key=val ...] [--as=ROLE] --format=json
82
+ Usage (json output is the default; --format=json accepted for back-compat):
83
+ textus list [--prefix=KEY] [--zone=Z]
84
+ textus where KEY
85
+ textus get KEY
86
+ textus put KEY --stdin [--fetch=NAME] --as=ROLE
87
+ textus stale [--prefix=KEY] [--zone=Z]
88
+ textus doctor
89
+ textus intro
90
+
91
+ textus key {mv,uid,migrate}
92
+ textus schema {show,init,diff,migrate}
93
+ textus hook {list,run}
434
94
  HELP
435
95
  end
436
96
  end
437
- # rubocop:enable Metrics/ClassLength
438
97
  end
@@ -0,0 +1,50 @@
1
+ require "json"
2
+
3
+ module Textus
4
+ module Doctor
5
+ class Check
6
+ class AuditLog < Check
7
+ def call
8
+ out = []
9
+ path = File.join(store.root, "audit.log")
10
+ return out unless File.exist?(path)
11
+
12
+ File.foreach(path).with_index(1) do |line, lineno| # rubocop:disable Metrics/BlockLength
13
+ stripped = line.chomp
14
+ next if stripped.empty?
15
+
16
+ if stripped.start_with?("{")
17
+ begin
18
+ JSON.parse(stripped)
19
+ rescue JSON::ParserError => e
20
+ out << {
21
+ "code" => "audit.parse_error",
22
+ "level" => "warning",
23
+ "subject" => "#{path}:#{lineno}",
24
+ "message" => "audit log line #{lineno} is invalid JSON: #{e.message}",
25
+ "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
26
+ }
27
+ end
28
+ else
29
+ # Legacy TSV (pre-0.5): read-only support retained for on-disk logs
30
+ # written by older textus versions. Never written by current code.
31
+ # Minimum 6 fields.
32
+ fields = stripped.split("\t")
33
+ next if fields.length >= 6
34
+
35
+ out << {
36
+ "code" => "audit.parse_error",
37
+ "level" => "warning",
38
+ "subject" => "#{path}:#{lineno}",
39
+ "message" => "audit log line #{lineno} has #{fields.length} fields " \
40
+ "(expected >=6 for legacy TSV; consider migrating to NDJSON)",
41
+ "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
42
+ }
43
+ end
44
+ end
45
+ out
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,29 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class Hooks < Check
5
+ def call
6
+ out = []
7
+ dir = File.join(store.root, "hooks")
8
+ return out unless File.directory?(dir)
9
+
10
+ Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
11
+ registry = Textus::Hooks::Registry.new
12
+ Textus.with_registry(registry) do
13
+ load(f)
14
+ end
15
+ rescue StandardError, ScriptError => e
16
+ out << {
17
+ "code" => "hook.load_failed",
18
+ "level" => "error",
19
+ "subject" => File.basename(f),
20
+ "message" => "#{e.class}: #{e.message}",
21
+ "fix" => "open #{f} and fix the syntax/load error",
22
+ }
23
+ end
24
+ out
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class IllegalKeys < Check
5
+ def call
6
+ out = []
7
+ store.manifest.entries.each do |entry|
8
+ next unless entry.nested
9
+
10
+ base = File.join(store.root, "zones", entry.path)
11
+ next unless File.directory?(base)
12
+
13
+ walk_nested(base) do |abs_path, is_dir|
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
29
+ end
30
+ out
31
+ end
32
+
33
+ private
34
+
35
+ def walk_nested(root, &block)
36
+ Dir.each_child(root) do |name|
37
+ abs = File.join(root, name)
38
+ if File.directory?(abs)
39
+ walk_nested(abs, &block)
40
+ yield abs, true
41
+ else
42
+ yield abs, false
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,38 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class ManifestFiles < Check
5
+ def call
6
+ out = []
7
+ store.manifest.entries.each do |entry|
8
+ next if entry.nested
9
+
10
+ path = leaf_path_for(entry)
11
+ next if File.exist?(path)
12
+
13
+ out << {
14
+ "code" => "manifest.missing_file",
15
+ "level" => "info",
16
+ "subject" => entry.key,
17
+ "message" => "declared entry has no file on disk at #{path}",
18
+ "fix" => "create the entry with 'textus put #{entry.key} --stdin --as=<role>' " \
19
+ "(or leave empty if not yet authored)",
20
+ }
21
+ end
22
+ out
23
+ end
24
+
25
+ private
26
+
27
+ def leaf_path_for(entry)
28
+ primary_ext = Entry.for_format(entry.format).extensions.first
29
+ if File.extname(entry.path) == ""
30
+ File.join(store.root, "zones", entry.path + primary_ext)
31
+ else
32
+ File.join(store.root, "zones", entry.path)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,22 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class SchemaViolations < Check
5
+ def call
6
+ res = store.validate_all
7
+ res["violations"].map do |v|
8
+ fix = v["expected"] &&
9
+ "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
10
+ {
11
+ "code" => v["code"],
12
+ "level" => "error",
13
+ "subject" => v["key"],
14
+ "message" => v["message"] || "#{v["code"]} on #{v["key"]}",
15
+ "fix" => fix,
16
+ }.compact
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class Schemas < Check
5
+ def call
6
+ out = []
7
+ store.manifest.entries.each do |entry|
8
+ next if entry.schema.nil?
9
+
10
+ sp = File.join(store.root, "schemas", "#{entry.schema}.yaml")
11
+ next if File.exist?(sp)
12
+
13
+ out << {
14
+ "code" => "schema.missing",
15
+ "level" => "error",
16
+ "subject" => entry.key,
17
+ "message" => "schema '#{entry.schema}' not found at #{sp}",
18
+ "fix" => "create the schema file or run 'textus schema init #{entry.schema} --from=<key>'",
19
+ }
20
+ end
21
+ out
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end