textus 0.4.0 → 0.5.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/README.md +13 -11
  4. data/SPEC.md +13 -9
  5. data/docs/architecture.md +63 -28
  6. data/lib/textus/audit_log.rb +46 -11
  7. data/lib/textus/builder.rb +3 -3
  8. data/lib/textus/builtin_actions.rb +5 -5
  9. data/lib/textus/cli/accept.rb +13 -0
  10. data/lib/textus/cli/action.rb +51 -0
  11. data/lib/textus/cli/build.rb +11 -0
  12. data/lib/textus/cli/delete.rb +14 -0
  13. data/lib/textus/cli/deprecated_alias.rb +31 -0
  14. data/lib/textus/cli/deps.rb +10 -0
  15. data/lib/textus/cli/doctor.rb +13 -0
  16. data/lib/textus/cli/extension_group.rb +9 -0
  17. data/lib/textus/cli/extensions.rb +49 -0
  18. data/lib/textus/cli/get.rb +10 -0
  19. data/lib/textus/cli/group.rb +51 -0
  20. data/lib/textus/cli/init.rb +12 -0
  21. data/lib/textus/cli/intro.rb +9 -0
  22. data/lib/textus/cli/key_group.rb +10 -0
  23. data/lib/textus/cli/list.rb +12 -0
  24. data/lib/textus/cli/migrate.rb +41 -0
  25. data/lib/textus/cli/migrate_keys.rb +19 -0
  26. data/lib/textus/cli/mv.rb +20 -0
  27. data/lib/textus/cli/published.rb +9 -0
  28. data/lib/textus/cli/put.rb +48 -0
  29. data/lib/textus/cli/rdeps.rb +10 -0
  30. data/lib/textus/cli/refresh.rb +13 -0
  31. data/lib/textus/cli/schema.rb +10 -0
  32. data/lib/textus/cli/schema_diff.rb +15 -0
  33. data/lib/textus/cli/schema_group.rb +33 -0
  34. data/lib/textus/cli/schema_init.rb +19 -0
  35. data/lib/textus/cli/schema_migrate.rb +19 -0
  36. data/lib/textus/cli/stale.rb +12 -0
  37. data/lib/textus/cli/uid.rb +15 -0
  38. data/lib/textus/cli/verb.rb +62 -0
  39. data/lib/textus/cli/where.rb +10 -0
  40. data/lib/textus/cli.rb +65 -387
  41. data/lib/textus/doctor.rb +64 -33
  42. data/lib/textus/entry/json.rb +6 -4
  43. data/lib/textus/entry/markdown.rb +4 -4
  44. data/lib/textus/entry/text.rb +3 -3
  45. data/lib/textus/entry/yaml.rb +6 -4
  46. data/lib/textus/entry.rb +2 -2
  47. data/lib/textus/errors.rb +2 -2
  48. data/lib/textus/init.rb +1 -1
  49. data/lib/textus/intro.rb +2 -2
  50. data/lib/textus/manifest.rb +11 -221
  51. data/lib/textus/manifest_entry.rb +185 -0
  52. data/lib/textus/migrate_v2.rb +27 -0
  53. data/lib/textus/projection.rb +1 -1
  54. data/lib/textus/proposal.rb +3 -3
  55. data/lib/textus/refresh.rb +7 -7
  56. data/lib/textus/schema_tools.rb +8 -8
  57. data/lib/textus/store/events.rb +31 -0
  58. data/lib/textus/store/mover.rb +118 -0
  59. data/lib/textus/store/staleness.rb +142 -0
  60. data/lib/textus/store/validator.rb +53 -0
  61. data/lib/textus/store.rb +49 -354
  62. data/lib/textus/version.rb +2 -2
  63. data/lib/textus.rb +38 -0
  64. metadata +38 -1
data/lib/textus/cli.rb CHANGED
@@ -1,12 +1,45 @@
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" => Accept,
10
+ "action" => Action,
11
+ "build" => Build,
12
+ "delete" => Delete,
13
+ "deps" => Deps,
14
+ "doctor" => DoctorVerb,
15
+ "extension" => ExtensionGroup,
16
+ "extensions" => Extensions,
17
+ "get" => Get,
18
+ "init" => InitVerb,
19
+ "intro" => IntroVerb,
20
+ "key" => KeyGroup,
21
+ "list" => List,
22
+ "migrate" => Migrate,
23
+ "migrate-keys" => MigrateKeysVerb,
24
+ "mv" => Mv,
25
+ "published" => Published,
26
+ "put" => Put,
27
+ "rdeps" => Rdeps,
28
+ "refresh" => RefreshVerb,
29
+ "schema" => SchemaGroup,
30
+ "schema-diff" => SchemaDiff,
31
+ "schema-init" => SchemaInit,
32
+ "schema-migrate" => SchemaMigrate,
33
+ "stale" => Stale,
34
+ "uid" => Uid,
35
+ "where" => Where,
36
+ }.freeze
37
+
38
+ # Flat aliases kept for backward-compat through 0.5; emit deprecation warnings.
39
+ DEPRECATED_ALIASES = %w[
40
+ mv uid migrate-keys schema-init schema-diff schema-migrate extensions action
41
+ ].freeze
42
+
10
43
  def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
11
44
  new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
12
45
  end
@@ -19,44 +52,19 @@ module Textus
19
52
  @root_arg = nil
20
53
  end
21
54
 
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)
55
+ def run(argv)
56
+ OptionParser.new { |o| o.on("--root=PATH") { |v| @root_arg = v } }.order!(argv)
26
57
  verb = argv.shift
27
58
  raise UsageError.new("missing verb") if verb.nil?
28
59
 
29
60
  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
61
  when "--version", "-v" then @stdout.puts(VERSION)
56
62
  0
57
63
  when "--help", "-h" then print_help
58
64
  0
59
- else raise UsageError.new("unknown verb: #{verb}")
65
+ else
66
+ klass = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
67
+ dispatch(klass, argv, deprecated_alias: DEPRECATED_ALIASES.include?(verb))
60
68
  end
61
69
  rescue Textus::Error => e
62
70
  emit_error(e)
@@ -68,348 +76,11 @@ module Textus
68
76
  @store ||= Store.discover(@cwd, root: @root_arg)
69
77
  end
70
78
 
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
79
+ def dispatch(klass, argv, deprecated_alias: false)
80
+ v = klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
81
+ v.deprecated_alias = true if deprecated_alias && v.respond_to?(:deprecated_alias=)
82
+ v.parse(argv)
83
+ v.call(klass.needs_store? ? store : nil)
413
84
  end
414
85
 
415
86
  def emit_error(err)
@@ -421,18 +92,25 @@ module Textus
421
92
 
422
93
  def print_help
423
94
  @stdout.puts <<~HELP
424
- textus #{VERSION} — reference implementation of #{PROTOCOL}
425
-
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
95
+ textus #{VERSION} — reference implementation of #{PROTOCOL} (was textus/1)
96
+
97
+ Usage (json output is the default; --format=json accepted for back-compat):
98
+ textus list [--prefix=KEY] [--zone=Z]
99
+ textus where KEY
100
+ textus get KEY
101
+ textus put KEY --stdin [--action=NAME] --as=ROLE
102
+ textus stale [--prefix=KEY] [--zone=Z]
103
+ textus doctor
104
+ textus intro
105
+ textus migrate v2
106
+
107
+ textus key {mv,uid,migrate}
108
+ textus schema {show,init,diff,migrate}
109
+ textus extension {list,run}
110
+
111
+ Deprecated (removed in 0.6): mv, uid, migrate-keys, schema-init,
112
+ schema-diff, schema-migrate, extensions, action.
434
113
  HELP
435
114
  end
436
115
  end
437
- # rubocop:enable Metrics/ClassLength
438
116
  end
data/lib/textus/doctor.rb CHANGED
@@ -6,23 +6,27 @@ module Textus
6
6
  # Health check for a Textus store. Returns a JSON-friendly Hash envelope
7
7
  # with an `issues` array and a summary. Each issue is a Hash with
8
8
  # `code`, `level`, `subject`, `message`, and optionally `fix`.
9
- module Doctor # rubocop:disable Metrics/ModuleLength -- 8 built-in checks + extension dispatch
9
+ module Doctor # rubocop:disable Metrics/ModuleLength -- 9 built-in checks + extension dispatch
10
10
  LEVELS = %w[error warning info].freeze
11
11
  DOCTOR_CHECK_TIMEOUT_SECONDS = 2
12
+ ALL_CHECKS = %w[
13
+ manifest_files schemas templates extensions illegal_keys
14
+ sentinels audit_log unowned_schema_fields schema_violations
15
+ ].freeze
12
16
 
13
17
  module_function
14
18
 
15
- def run(store)
16
- issues = []
17
- issues.concat(check_manifest_files(store))
18
- issues.concat(check_schemas(store))
19
- issues.concat(check_templates(store))
20
- issues.concat(check_extensions(store))
21
- issues.concat(check_illegal_keys(store))
22
- issues.concat(check_sentinels(store))
23
- issues.concat(check_audit_log(store))
24
- issues.concat(check_unowned_schema_fields(store))
25
- issues.concat(run_registered_checks(store))
19
+ def run(store, checks: nil)
20
+ selected = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
21
+ unknown = selected - ALL_CHECKS
22
+ unless unknown.empty?
23
+ raise UsageError.new(
24
+ "unknown doctor check: #{unknown.first}. Valid checks: #{ALL_CHECKS.join(", ")}",
25
+ )
26
+ end
27
+
28
+ issues = run_builtin_checks(store, selected)
29
+ issues.concat(run_registered_checks(store)) # extensions always run
26
30
 
27
31
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
28
32
  {
@@ -33,6 +37,20 @@ module Textus
33
37
  }
34
38
  end
35
39
 
40
+ def run_builtin_checks(store, selected)
41
+ issues = []
42
+ issues.concat(check_manifest_files(store)) if selected.include?("manifest_files")
43
+ issues.concat(check_schemas(store)) if selected.include?("schemas")
44
+ issues.concat(check_templates(store)) if selected.include?("templates")
45
+ issues.concat(check_extensions(store)) if selected.include?("extensions")
46
+ issues.concat(check_illegal_keys(store)) if selected.include?("illegal_keys")
47
+ issues.concat(check_sentinels(store)) if selected.include?("sentinels")
48
+ issues.concat(check_audit_log(store)) if selected.include?("audit_log")
49
+ issues.concat(check_unowned_schema_fields(store)) if selected.include?("unowned_schema_fields")
50
+ issues.concat(check_schema_violations(store)) if selected.include?("schema_violations")
51
+ issues
52
+ end
53
+
36
54
  # --- Checks -----------------------------------------------------------
37
55
 
38
56
  def check_manifest_files(store)
@@ -199,32 +217,30 @@ module Textus
199
217
  stripped = line.chomp
200
218
  next if stripped.empty?
201
219
 
202
- # Audit log is TSV, not NDJSON. Treat as malformed if it doesn't have
203
- # at least 6 tab-separated fields (timestamp, role, verb, key, etag_before, etag_after).
204
- fields = stripped.split("\t")
205
- if fields.length < 6
206
- out << {
207
- "code" => "audit.parse_error",
208
- "level" => "warning",
209
- "subject" => "#{path}:#{lineno}",
210
- "message" => "audit log line #{lineno} has #{fields.length} fields (expected >=6)",
211
- "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
212
- }
213
- next
214
- end
215
-
216
- extras = fields[6]
217
- next if extras.nil? || extras.empty?
220
+ if stripped.start_with?("{")
221
+ begin
222
+ JSON.parse(stripped)
223
+ rescue JSON::ParserError => e
224
+ out << {
225
+ "code" => "audit.parse_error",
226
+ "level" => "warning",
227
+ "subject" => "#{path}:#{lineno}",
228
+ "message" => "audit log line #{lineno} is invalid JSON: #{e.message}",
229
+ "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
230
+ }
231
+ end
232
+ else
233
+ # Legacy TSV: minimum 6 fields. Removed in 0.6.
234
+ fields = stripped.split("\t")
235
+ next if fields.length >= 6
218
236
 
219
- begin
220
- JSON.parse(extras)
221
- rescue JSON::ParserError => e
222
237
  out << {
223
238
  "code" => "audit.parse_error",
224
239
  "level" => "warning",
225
240
  "subject" => "#{path}:#{lineno}",
226
- "message" => "audit log line #{lineno} extras JSON malformed: #{e.message}",
227
- "fix" => "inspect #{path} at line #{lineno} and fix the JSON in the last column",
241
+ "message" => "audit log line #{lineno} has #{fields.length} fields " \
242
+ "(expected >=6 for legacy TSV; consider migrating to NDJSON)",
243
+ "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
228
244
  }
229
245
  end
230
246
  end
@@ -258,6 +274,21 @@ module Textus
258
274
  out
259
275
  end
260
276
 
277
+ def check_schema_violations(store)
278
+ res = store.validate_all
279
+ res["violations"].map do |v|
280
+ fix = v["expected"] &&
281
+ "field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
282
+ {
283
+ "code" => v["code"],
284
+ "level" => "error",
285
+ "subject" => v["key"],
286
+ "message" => v["message"] || "#{v["code"]} on #{v["key"]}",
287
+ "fix" => fix,
288
+ }.compact
289
+ end
290
+ end
291
+
261
292
  def run_registered_checks(store)
262
293
  out = []
263
294
  view = StoreView.new(store)
@@ -17,13 +17,15 @@ module Textus
17
17
 
18
18
  meta = parsed["_meta"]
19
19
  fm = meta.is_a?(Hash) ? meta : {}
20
- { "frontmatter" => fm, "body" => raw, "content" => parsed }
20
+ content_without_meta = parsed.except("_meta")
21
+ { "_meta" => fm, "body" => raw, "content" => content_without_meta }
21
22
  end
22
23
 
23
- def self.serialize(frontmatter:, body:, content: nil)
24
- _ = frontmatter
24
+ def self.serialize(meta:, body:, content: nil)
25
25
  if content.is_a?(Hash)
26
- out = ::JSON.pretty_generate(content)
26
+ # Re-inject _meta as the first key so on-disk shape is stable.
27
+ on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
+ out = ::JSON.pretty_generate(on_disk)
27
29
  out += "\n" unless out.end_with?("\n")
28
30
  out
29
31
  elsif body && !body.to_s.empty?
@@ -7,7 +7,7 @@ module Textus
7
7
  def self.parse(raw, path: nil)
8
8
  raw = raw.dup.force_encoding(Encoding::UTF_8)
9
9
  raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
10
- return { "frontmatter" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
10
+ return { "_meta" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
11
11
 
12
12
  lines = raw.split(/\r?\n/, -1)
13
13
  close_idx = lines[1..].index("---")
@@ -22,12 +22,12 @@ module Textus
22
22
  raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
23
23
  end
24
24
  fm = {} unless fm.is_a?(Hash)
25
- { "frontmatter" => fm, "body" => body, "content" => nil }
25
+ { "_meta" => fm, "body" => body, "content" => nil }
26
26
  end
27
27
 
28
- def self.serialize(frontmatter:, body:, content: nil)
28
+ def self.serialize(meta:, body:, content: nil)
29
29
  _ = content # markdown ignores content
30
- fm_yaml = frontmatter.empty? ? "" : ::YAML.dump(frontmatter).sub(/\A---\n/, "")
30
+ fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
31
31
  body = body.to_s
32
32
  body += "\n" unless body.empty? || body.end_with?("\n")
33
33
  "---\n#{fm_yaml}---\n#{body}"
@@ -6,11 +6,11 @@ module Textus
6
6
  raw = raw.dup.force_encoding(Encoding::UTF_8)
7
7
  raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
8
8
 
9
- { "frontmatter" => {}, "body" => raw, "content" => nil }
9
+ { "_meta" => {}, "body" => raw, "content" => nil }
10
10
  end
11
11
 
12
- def self.serialize(frontmatter:, body:, content: nil)
13
- _ = frontmatter
12
+ def self.serialize(meta:, body:, content: nil)
13
+ _ = meta
14
14
  _ = content
15
15
  b = body.to_s
16
16
  b += "\n" unless b.empty? || b.end_with?("\n")