textus 0.2.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.
data/lib/textus/cli.rb ADDED
@@ -0,0 +1,394 @@
1
+ require "json"
2
+ require "optparse"
3
+ require "time"
4
+ require "timeout"
5
+ require "yaml"
6
+
7
+ module Textus
8
+ # rubocop:disable Metrics/ClassLength
9
+ class CLI
10
+ def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
11
+ new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
12
+ end
13
+
14
+ def initialize(stdin:, stdout:, stderr:, cwd:)
15
+ @stdin = stdin
16
+ @stdout = stdout
17
+ @stderr = stderr
18
+ @cwd = cwd
19
+ end
20
+
21
+ def run(argv) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
22
+ verb = argv.shift
23
+ raise UsageError.new("missing verb") if verb.nil?
24
+
25
+ case verb
26
+ when "list" then verb_list(argv)
27
+ when "where" then verb_where(argv)
28
+ when "get" then verb_get(argv)
29
+ when "put" then verb_put(argv)
30
+ when "schema" then verb_schema(argv)
31
+ when "stale" then verb_stale(argv)
32
+ when "delete" then verb_delete(argv)
33
+ when "validate-all" then verb_validate_all(argv)
34
+ when "build" then verb_build(argv)
35
+ when "deps" then verb_deps(argv)
36
+ when "rdeps" then verb_rdeps(argv)
37
+ when "published" then verb_published(argv)
38
+ when "accept" then verb_accept(argv)
39
+ when "init" then verb_init(argv)
40
+ when "schema-init" then verb_schema_init(argv)
41
+ when "schema-diff" then verb_schema_diff(argv)
42
+ when "schema-migrate" then verb_schema_migrate(argv)
43
+ when "refresh" then verb_refresh(argv)
44
+ when "extensions" then verb_extensions(argv)
45
+ when "migrate-keys" then verb_migrate_keys(argv)
46
+ when "mv" then verb_mv(argv)
47
+ when "uid" then verb_uid(argv)
48
+ when "doctor" then verb_doctor(argv)
49
+ when "intro" then verb_intro(argv)
50
+ when "--version", "-v" then @stdout.puts(VERSION)
51
+ 0
52
+ when "--help", "-h" then print_help
53
+ 0
54
+ else raise UsageError.new("unknown verb: #{verb}")
55
+ end
56
+ rescue Textus::Error => e
57
+ emit_error(e)
58
+ end
59
+
60
+ private
61
+
62
+ def store
63
+ @store ||= Store.discover(@cwd)
64
+ end
65
+
66
+ def parse_format!(argv)
67
+ fmt = "text"
68
+ OptionParser.new do |o|
69
+ o.on("--format=FMT") { |v| fmt = v }
70
+ end.permute!(argv)
71
+ raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
72
+
73
+ fmt
74
+ end
75
+
76
+ def parse_prefix!(argv)
77
+ prefix = nil
78
+ OptionParser.new do |o|
79
+ o.on("--prefix=KEY") { |v| prefix = v }
80
+ o.on("--zone=Z") {}
81
+ o.on("--format=FMT") {}
82
+ end.permute!(argv)
83
+ prefix
84
+ end
85
+
86
+ def parse_prefix_and_zone!(argv)
87
+ prefix = nil
88
+ zone = nil
89
+ fmt = "text"
90
+ OptionParser.new do |o|
91
+ o.on("--prefix=KEY") { |v| prefix = v }
92
+ o.on("--zone=Z") { |v| zone = v }
93
+ o.on("--format=FMT") { |v| fmt = v }
94
+ end.permute!(argv)
95
+ raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
96
+
97
+ [prefix, zone]
98
+ end
99
+
100
+ def verb_list(argv)
101
+ prefix, zone = parse_prefix_and_zone!(argv)
102
+ emit({ "protocol" => PROTOCOL, "entries" => store.list(prefix: prefix, zone: zone) })
103
+ end
104
+
105
+ def verb_where(argv)
106
+ key = argv.shift or raise UsageError.new("where requires a key")
107
+ parse_format!(argv)
108
+ emit(store.where(key))
109
+ end
110
+
111
+ def verb_get(argv)
112
+ key = argv.shift or raise UsageError.new("get requires a key")
113
+ parse_format!(argv)
114
+ emit(store.get(key))
115
+ end
116
+
117
+ def verb_schema(argv)
118
+ key = argv.shift or raise UsageError.new("schema requires a key")
119
+ parse_format!(argv)
120
+ emit(store.schema_envelope(key))
121
+ end
122
+
123
+ def verb_stale(argv)
124
+ prefix, zone = parse_prefix_and_zone!(argv)
125
+ emit(store.stale(prefix: prefix, zone: zone))
126
+ end
127
+
128
+ def verb_put(argv) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
129
+ key = argv.shift or raise UsageError.new("put requires a key")
130
+ as_flag = nil
131
+ use_stdin = false
132
+ fetcher_name = nil
133
+ OptionParser.new do |o|
134
+ o.on("--stdin") { use_stdin = true }
135
+ o.on("--as=ROLE") { |v| as_flag = v }
136
+ o.on("--fetcher=NAME") { |v| fetcher_name = v }
137
+ o.on("--format=FMT") {}
138
+ end.permute!(argv)
139
+ raise UsageError.new("put requires --stdin in v1") unless use_stdin
140
+
141
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
142
+
143
+ raw = @stdin.read
144
+ payload =
145
+ if fetcher_name
146
+ callable = store.registry.fetcher(fetcher_name)
147
+ result =
148
+ begin
149
+ Timeout.timeout(Textus::Refresh::FETCHER_TIMEOUT_SECONDS) do
150
+ callable.call(config: { "bytes" => raw }, store: Textus::StoreView.new(store))
151
+ end
152
+ rescue Timeout::Error
153
+ raise UsageError.new(
154
+ "fetcher '#{fetcher_name}' exceeded #{Textus::Refresh::FETCHER_TIMEOUT_SECONDS}s timeout",
155
+ )
156
+ end
157
+ basename = key.split(".").last
158
+ {
159
+ "frontmatter" => {
160
+ "name" => basename,
161
+ "last_refreshed_at" => Time.now.utc.iso8601,
162
+ "fetched_with" => fetcher_name,
163
+ }.merge(result[:frontmatter] || result["frontmatter"] || {}),
164
+ "body" => result[:body] || result["body"] || "",
165
+ }
166
+ else
167
+ JSON.parse(raw)
168
+ end
169
+
170
+ fm = payload["frontmatter"] || {}
171
+ body = payload["body"] || ""
172
+ if_etag = payload["if_etag"]
173
+ emit(store.put(key, frontmatter: fm, body: body, if_etag: if_etag, as: role))
174
+ end
175
+
176
+ def verb_delete(argv)
177
+ key = argv.shift or raise UsageError.new("delete requires a key")
178
+ as_flag = nil
179
+ if_etag = nil
180
+ OptionParser.new do |o|
181
+ o.on("--as=ROLE") { |v| as_flag = v }
182
+ o.on("--if-etag=E") { |v| if_etag = v }
183
+ o.on("--format=FMT") {}
184
+ end.permute!(argv)
185
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
186
+ emit(store.delete(key, if_etag: if_etag, as: role))
187
+ end
188
+
189
+ def verb_validate_all(argv)
190
+ parse_format!(argv)
191
+ res = store.validate_all
192
+ @stdout.puts(JSON.generate(res))
193
+ res["ok"] ? 0 : 1
194
+ end
195
+
196
+ def verb_build(argv)
197
+ prefix = nil
198
+ OptionParser.new do |o|
199
+ o.on("--prefix=K") { |v| prefix = v }
200
+ o.on("--format=FMT") {}
201
+ end.permute!(argv)
202
+ res = Textus::Builder.new(store).build(prefix: prefix)
203
+ @stdout.puts(JSON.generate(res))
204
+ 0
205
+ end
206
+
207
+ def verb_deps(argv)
208
+ key = argv.shift or raise UsageError.new("deps requires a key")
209
+ parse_format!(argv)
210
+ emit({ "protocol" => Textus::PROTOCOL, "key" => key, "deps" => store.deps(key) })
211
+ end
212
+
213
+ def verb_rdeps(argv)
214
+ key = argv.shift or raise UsageError.new("rdeps requires a key")
215
+ parse_format!(argv)
216
+ emit({ "protocol" => Textus::PROTOCOL, "key" => key, "rdeps" => store.rdeps(key) })
217
+ end
218
+
219
+ def verb_schema_init(argv)
220
+ name = argv.shift or raise UsageError.new("schema-init NAME")
221
+ from_key = nil
222
+ OptionParser.new do |o|
223
+ o.on("--from=KEY") { |v| from_key = v }
224
+ o.on("--format=FMT") {}
225
+ end.permute!(argv)
226
+ raise UsageError.new("schema-init requires --from=KEY") unless from_key
227
+
228
+ emit(Textus::SchemaTools.init(store, name: name, from: from_key))
229
+ end
230
+
231
+ def verb_schema_diff(argv)
232
+ name = argv.shift or raise UsageError.new("schema-diff NAME")
233
+ parse_format!(argv)
234
+ emit(Textus::SchemaTools.diff(store, name: name))
235
+ end
236
+
237
+ def verb_schema_migrate(argv)
238
+ name = argv.shift or raise UsageError.new("schema-migrate NAME")
239
+ rename = nil
240
+ OptionParser.new do |o|
241
+ o.on("--rename=O:N") { |v| rename = v }
242
+ o.on("--format=FMT") {}
243
+ end.permute!(argv)
244
+ raise UsageError.new("schema-migrate requires --rename=OLD:NEW") unless rename
245
+
246
+ emit(Textus::SchemaTools.migrate(store, name: name, rename: rename))
247
+ end
248
+
249
+ def verb_init(argv)
250
+ OptionParser.new do |o|
251
+ o.on("--format=FMT") {}
252
+ end.permute!(argv)
253
+ target = File.join(@cwd, ".textus")
254
+ res = Textus::Init.run(target)
255
+ @stdout.puts(JSON.generate(res))
256
+ 0
257
+ end
258
+
259
+ def verb_accept(argv)
260
+ key = argv.shift or raise UsageError.new("accept requires a key")
261
+ as_flag = nil
262
+ OptionParser.new do |o|
263
+ o.on("--as=ROLE") { |v| as_flag = v }
264
+ o.on("--format=FMT") {}
265
+ end.permute!(argv)
266
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
267
+ emit(store.accept(key, as: role))
268
+ end
269
+
270
+ def verb_refresh(argv)
271
+ key = argv.shift or raise UsageError.new("refresh requires a key")
272
+ as_flag = nil
273
+ OptionParser.new do |o|
274
+ o.on("--as=ROLE") { |v| as_flag = v }
275
+ o.on("--format=FMT") {}
276
+ end.permute!(argv)
277
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
278
+ emit(Textus::Refresh.call(store, key, as: role))
279
+ end
280
+
281
+ def verb_extensions(argv) # rubocop:disable Metrics/AbcSize
282
+ subcommand = argv.shift
283
+ raise UsageError.new("extensions requires 'list'") unless subcommand == "list"
284
+
285
+ kind = nil
286
+ OptionParser.new do |o|
287
+ o.on("--kind=K") { |v| kind = v }
288
+ o.on("--format=FMT") {}
289
+ end.permute!(argv)
290
+
291
+ rows = []
292
+ rows += store.registry.fetcher_names.map { |n| { "kind" => "fetcher", "name" => n.to_s } }
293
+ rows += store.registry.reducer_names.map { |n| { "kind" => "reducer", "name" => n.to_s } }
294
+ store.registry.hook_events.each do |evt|
295
+ store.registry.hooks(evt).each do |h|
296
+ rows << { "kind" => "hook", "event" => evt.to_s, "name" => h[:name].to_s }
297
+ end
298
+ end
299
+ store.manifest.entries.each do |e|
300
+ e.events.each do |evt, defs|
301
+ Array(defs).each do |defn|
302
+ next unless defn["exec"]
303
+
304
+ rows << {
305
+ "kind" => "hook", "event" => evt.to_s, "exec" => defn["exec"],
306
+ "key" => e.key, "as" => defn["as"] || "script"
307
+ }
308
+ end
309
+ end
310
+ end
311
+ rows.select! { |r| r["kind"] == kind } if kind
312
+
313
+ emit({ "protocol" => Textus::PROTOCOL, "extensions" => rows })
314
+ end
315
+
316
+ def verb_migrate_keys(argv)
317
+ write = false
318
+ OptionParser.new do |o|
319
+ o.on("--dry-run") { write = false }
320
+ o.on("--write") { write = true }
321
+ o.on("--format=FMT") {}
322
+ end.permute!(argv)
323
+ res = Textus::MigrateKeys.run(store, write: write)
324
+ @stdout.puts(JSON.generate(res))
325
+ res["ok"] ? 0 : 1
326
+ end
327
+
328
+ def verb_mv(argv)
329
+ old_key = argv.shift or raise UsageError.new("mv requires <old-key> <new-key>")
330
+ new_key = argv.shift or raise UsageError.new("mv requires <old-key> <new-key>")
331
+ as_flag = nil
332
+ dry_run = false
333
+ OptionParser.new do |o|
334
+ o.on("--as=ROLE") { |v| as_flag = v }
335
+ o.on("--dry-run") { dry_run = true }
336
+ o.on("--format=FMT") {}
337
+ end.permute!(argv)
338
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
339
+ emit(store.mv(old_key, new_key, as: role, dry_run: dry_run))
340
+ end
341
+
342
+ def verb_uid(argv)
343
+ key = argv.shift or raise UsageError.new("uid requires a key")
344
+ parse_format!(argv)
345
+ emit({ "protocol" => PROTOCOL, "key" => key, "uid" => store.uid(key) })
346
+ end
347
+
348
+ def verb_intro(argv)
349
+ parse_format!(argv)
350
+ emit(Textus::Intro.run(store))
351
+ end
352
+
353
+ def verb_doctor(argv)
354
+ OptionParser.new do |o|
355
+ o.on("--format=FMT") {}
356
+ end.permute!(argv)
357
+ res = Textus::Doctor.run(store)
358
+ @stdout.puts(JSON.generate(res))
359
+ res["ok"] ? 0 : 1
360
+ end
361
+
362
+ def verb_published(argv)
363
+ parse_format!(argv)
364
+ emit({ "protocol" => Textus::PROTOCOL, "published" => store.published })
365
+ end
366
+
367
+ def emit(obj)
368
+ @stdout.puts(JSON.generate(obj))
369
+ 0
370
+ end
371
+
372
+ def emit_error(err)
373
+ @stdout.puts(JSON.generate(err.to_envelope))
374
+ @stderr.puts("#{err.code}: #{err.message}")
375
+ @stderr.puts(" → #{err.hint}") if err.hint
376
+ err.exit_code
377
+ end
378
+
379
+ def print_help
380
+ @stdout.puts <<~HELP
381
+ textus #{VERSION} — reference implementation of #{PROTOCOL}
382
+
383
+ Usage:
384
+ textus list [--prefix=KEY] --format=json
385
+ textus where KEY --format=json
386
+ textus get KEY --format=json
387
+ textus put KEY --stdin --format=json
388
+ textus schema KEY --format=json
389
+ textus stale [--prefix=KEY] --format=json
390
+ HELP
391
+ end
392
+ end
393
+ # rubocop:enable Metrics/ClassLength
394
+ end
@@ -0,0 +1,23 @@
1
+ module Textus
2
+ module Dependencies
3
+ def self.deps_of(manifest, key)
4
+ entry = manifest.entries.find { |e| e.key == key } or return []
5
+ result = Array(entry.projection&.fetch("select", nil)).map { |s| s }
6
+ Array(entry.generator&.fetch("sources", nil)).each { |s| result << s }
7
+ result.uniq
8
+ end
9
+
10
+ def self.rdeps_of(manifest, key)
11
+ manifest.entries.each_with_object([]) do |e, acc|
12
+ sources = Array(e.projection&.fetch("select", nil)) + Array(e.generator&.fetch("sources", nil))
13
+ acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
14
+ end
15
+ end
16
+
17
+ def self.published_of(manifest)
18
+ manifest.entries.reject { |e| e.publish_to.empty? }.map do |e|
19
+ { "key" => e.key, "publish_to" => e.publish_to }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,281 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module Textus
5
+ # Health check for a Textus store. Returns a JSON-friendly Hash envelope
6
+ # with an `issues` array and a summary. Each issue is a Hash with
7
+ # `code`, `level`, `subject`, `message`, and optionally `fix`.
8
+ module Doctor
9
+ LEVELS = %w[error warning info].freeze
10
+
11
+ module_function
12
+
13
+ def run(store)
14
+ issues = []
15
+ issues.concat(check_manifest_files(store))
16
+ issues.concat(check_schemas(store))
17
+ issues.concat(check_templates(store))
18
+ issues.concat(check_extensions(store))
19
+ issues.concat(check_illegal_keys(store))
20
+ issues.concat(check_sentinels(store))
21
+ issues.concat(check_audit_log(store))
22
+ issues.concat(check_unowned_schema_fields(store))
23
+
24
+ summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
25
+ {
26
+ "protocol" => Textus::PROTOCOL,
27
+ "ok" => summary["error"].zero?,
28
+ "issues" => issues,
29
+ "summary" => summary,
30
+ }
31
+ end
32
+
33
+ # --- Checks -----------------------------------------------------------
34
+
35
+ def check_manifest_files(store)
36
+ out = []
37
+ store.manifest.entries.each do |entry|
38
+ next if entry.nested
39
+
40
+ path = leaf_path_for(store, entry)
41
+ next if File.exist?(path)
42
+
43
+ out << {
44
+ "code" => "manifest.missing_file",
45
+ "level" => "info",
46
+ "subject" => entry.key,
47
+ "message" => "declared entry has no file on disk at #{path}",
48
+ "fix" => "create the entry with 'textus put #{entry.key} --stdin --as=<role>' " \
49
+ "(or leave empty if not yet authored)",
50
+ }
51
+ end
52
+ out
53
+ end
54
+
55
+ def check_schemas(store)
56
+ out = []
57
+ store.manifest.entries.each do |entry|
58
+ next if entry.schema.nil?
59
+
60
+ sp = File.join(store.root, "schemas", "#{entry.schema}.yaml")
61
+ next if File.exist?(sp)
62
+
63
+ out << {
64
+ "code" => "schema.missing",
65
+ "level" => "error",
66
+ "subject" => entry.key,
67
+ "message" => "schema '#{entry.schema}' not found at #{sp}",
68
+ "fix" => "create the schema file or run 'textus schema-init #{entry.schema} --from=<key>'",
69
+ }
70
+ end
71
+ out
72
+ end
73
+
74
+ def check_templates(store)
75
+ out = []
76
+ store.manifest.entries.each do |entry|
77
+ next if entry.template.nil?
78
+
79
+ tp = File.join(store.root, "templates", entry.template)
80
+ next if File.exist?(tp)
81
+
82
+ out << {
83
+ "code" => "template.missing",
84
+ "level" => "error",
85
+ "subject" => entry.key,
86
+ "message" => "template '#{entry.template}' not found at #{tp}",
87
+ "fix" => "create the file at #{tp} or update the entry's template: field",
88
+ }
89
+ end
90
+ out
91
+ end
92
+
93
+ def check_extensions(store)
94
+ out = []
95
+ dir = File.join(store.root, "extensions")
96
+ return out unless File.directory?(dir)
97
+
98
+ Dir.glob(File.join(dir, "*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
99
+ registry = ExtensionRegistry.new
100
+ Textus.with_registry(registry) do
101
+ load(f)
102
+ end
103
+ rescue StandardError, ScriptError => e
104
+ out << {
105
+ "code" => "extension.load_failed",
106
+ "level" => "error",
107
+ "subject" => File.basename(f),
108
+ "message" => "#{e.class}: #{e.message}",
109
+ "fix" => "open #{f} and fix the syntax/load error",
110
+ }
111
+ end
112
+ out
113
+ end
114
+
115
+ def check_illegal_keys(store)
116
+ out = []
117
+ store.manifest.entries.each do |entry|
118
+ next unless entry.nested
119
+
120
+ base = File.join(store.root, "zones", entry.path)
121
+ next unless File.directory?(base)
122
+
123
+ walk_nested(base) do |abs_path, is_dir|
124
+ basename = File.basename(abs_path)
125
+ stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
126
+ next if stem.match?(Manifest::KEY_SEGMENT)
127
+
128
+ proposed = Textus::MigrateKeys.normalize(stem)
129
+ out << {
130
+ "code" => "key.illegal",
131
+ "level" => "error",
132
+ "subject" => abs_path,
133
+ "path" => abs_path,
134
+ "proposed_key" => proposed,
135
+ "message" => "illegal key segment '#{stem}' at #{abs_path}",
136
+ "fix" => "run 'textus migrate-keys --dry-run' then '--write' to rename to '#{proposed}'",
137
+ }
138
+ end
139
+ end
140
+ out
141
+ end
142
+
143
+ def check_sentinels(store)
144
+ out = []
145
+ dir = File.join(store.root, "sentinels")
146
+ return out unless File.directory?(dir)
147
+
148
+ Dir.glob(File.join(dir, "**", "*.textus-managed.json")).each do |sp| # rubocop:disable Metrics/BlockLength
149
+ begin
150
+ data = JSON.parse(File.read(sp))
151
+ rescue JSON::ParserError => e
152
+ out << {
153
+ "code" => "sentinel.parse_error",
154
+ "level" => "warning",
155
+ "subject" => sp,
156
+ "message" => "sentinel is not valid JSON: #{e.message}",
157
+ "fix" => "delete #{sp} and re-run 'textus build' to regenerate",
158
+ }
159
+ next
160
+ end
161
+
162
+ target = data["target"]
163
+ recorded_sha = data["sha256"]
164
+
165
+ if target.nil? || !File.exist?(target)
166
+ out << {
167
+ "code" => "sentinel.orphan",
168
+ "level" => "warning",
169
+ "subject" => sp,
170
+ "message" => "sentinel target #{target.inspect} no longer exists",
171
+ "fix" => "delete #{sp} (the published file is gone) or restore the target",
172
+ }
173
+ next
174
+ end
175
+
176
+ current_sha = Digest::SHA256.hexdigest(File.binread(target))
177
+ next if recorded_sha.nil? || current_sha == recorded_sha
178
+
179
+ out << {
180
+ "code" => "sentinel.drift",
181
+ "level" => "warning",
182
+ "subject" => target,
183
+ "message" => "published file at #{target} was modified out-of-band",
184
+ "fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
185
+ }
186
+ end
187
+ out
188
+ end
189
+
190
+ def check_audit_log(store)
191
+ out = []
192
+ path = File.join(store.root, "audit.log")
193
+ return out unless File.exist?(path)
194
+
195
+ File.foreach(path).with_index(1) do |line, lineno| # rubocop:disable Metrics/BlockLength
196
+ stripped = line.chomp
197
+ next if stripped.empty?
198
+
199
+ # Audit log is TSV, not NDJSON. Treat as malformed if it doesn't have
200
+ # at least 6 tab-separated fields (timestamp, role, verb, key, etag_before, etag_after).
201
+ fields = stripped.split("\t")
202
+ if fields.length < 6
203
+ out << {
204
+ "code" => "audit.parse_error",
205
+ "level" => "warning",
206
+ "subject" => "#{path}:#{lineno}",
207
+ "message" => "audit log line #{lineno} has #{fields.length} fields (expected >=6)",
208
+ "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
209
+ }
210
+ next
211
+ end
212
+
213
+ extras = fields[6]
214
+ next if extras.nil? || extras.empty?
215
+
216
+ begin
217
+ JSON.parse(extras)
218
+ rescue JSON::ParserError => e
219
+ out << {
220
+ "code" => "audit.parse_error",
221
+ "level" => "warning",
222
+ "subject" => "#{path}:#{lineno}",
223
+ "message" => "audit log line #{lineno} extras JSON malformed: #{e.message}",
224
+ "fix" => "inspect #{path} at line #{lineno} and fix the JSON in the last column",
225
+ }
226
+ end
227
+ end
228
+ out
229
+ end
230
+
231
+ def check_unowned_schema_fields(store)
232
+ out = []
233
+ dir = File.join(store.root, "schemas")
234
+ return out unless File.directory?(dir)
235
+
236
+ Dir.glob(File.join(dir, "*.yaml")).sort.each do |sp| # rubocop:disable Lint/RedundantDirGlobSort
237
+ schema = begin
238
+ Schema.load(sp)
239
+ rescue StandardError
240
+ next
241
+ end
242
+ unowned = schema.fields.each_with_object([]) do |(name, spec), acc|
243
+ acc << name if spec.is_a?(Hash) && spec["maintained_by"].nil?
244
+ end
245
+ next if unowned.empty?
246
+
247
+ out << {
248
+ "code" => "schema.unowned_fields",
249
+ "level" => "info",
250
+ "subject" => schema.name || File.basename(sp, ".yaml"),
251
+ "message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
252
+ "fix" => "add 'maintained_by: <role>' to each field in #{sp} (optional but recommended)",
253
+ }
254
+ end
255
+ out
256
+ end
257
+
258
+ # --- Helpers ----------------------------------------------------------
259
+
260
+ def leaf_path_for(store, entry)
261
+ primary_ext = Entry.for_format(entry.format).extensions.first
262
+ if File.extname(entry.path) == ""
263
+ File.join(store.root, "zones", entry.path + primary_ext)
264
+ else
265
+ File.join(store.root, "zones", entry.path)
266
+ end
267
+ end
268
+
269
+ def walk_nested(root, &block)
270
+ Dir.each_child(root) do |name|
271
+ abs = File.join(root, name)
272
+ if File.directory?(abs)
273
+ walk_nested(abs, &block)
274
+ yield abs, true
275
+ else
276
+ yield abs, false
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end