textus 0.5.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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +29 -21
  4. data/SPEC.md +75 -142
  5. data/docs/architecture.md +42 -23
  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/verb/accept.rb +15 -0
  17. data/lib/textus/cli/verb/build.rb +13 -0
  18. data/lib/textus/cli/verb/delete.rb +16 -0
  19. data/lib/textus/cli/verb/deps.rb +12 -0
  20. data/lib/textus/cli/verb/doctor.rb +15 -0
  21. data/lib/textus/cli/verb/get.rb +12 -0
  22. data/lib/textus/cli/verb/hook_run.rb +48 -0
  23. data/lib/textus/cli/verb/hooks.rb +50 -0
  24. data/lib/textus/cli/verb/init.rb +14 -0
  25. data/lib/textus/cli/verb/intro.rb +11 -0
  26. data/lib/textus/cli/verb/list.rb +14 -0
  27. data/lib/textus/cli/verb/migrate_keys.rb +16 -0
  28. data/lib/textus/cli/verb/mv.rb +17 -0
  29. data/lib/textus/cli/verb/published.rb +11 -0
  30. data/lib/textus/cli/verb/put.rb +50 -0
  31. data/lib/textus/cli/verb/rdeps.rb +12 -0
  32. data/lib/textus/cli/verb/refresh.rb +15 -0
  33. data/lib/textus/cli/verb/schema.rb +12 -0
  34. data/lib/textus/cli/verb/schema_diff.rb +12 -0
  35. data/lib/textus/cli/verb/schema_init.rb +16 -0
  36. data/lib/textus/cli/verb/schema_migrate.rb +16 -0
  37. data/lib/textus/cli/verb/stale.rb +14 -0
  38. data/lib/textus/cli/verb/uid.rb +12 -0
  39. data/lib/textus/cli/verb/where.rb +12 -0
  40. data/lib/textus/cli.rb +23 -42
  41. data/lib/textus/doctor/check/audit_log.rb +50 -0
  42. data/lib/textus/doctor/check/hooks.rb +29 -0
  43. data/lib/textus/doctor/check/illegal_keys.rb +49 -0
  44. data/lib/textus/doctor/check/manifest_files.rb +38 -0
  45. data/lib/textus/doctor/check/schema_violations.rb +22 -0
  46. data/lib/textus/doctor/check/schemas.rb +26 -0
  47. data/lib/textus/doctor/check/sentinels.rb +57 -0
  48. data/lib/textus/doctor/check/templates.rb +26 -0
  49. data/lib/textus/doctor/check/unowned_schema_fields.rb +34 -0
  50. data/lib/textus/doctor/check.rb +30 -0
  51. data/lib/textus/doctor.rb +22 -288
  52. data/lib/textus/entry/base.rb +30 -0
  53. data/lib/textus/entry/json.rb +5 -1
  54. data/lib/textus/entry/markdown.rb +1 -1
  55. data/lib/textus/entry/text.rb +1 -1
  56. data/lib/textus/entry/yaml.rb +5 -1
  57. data/lib/textus/entry.rb +0 -5
  58. data/lib/textus/envelope.rb +30 -0
  59. data/lib/textus/hooks/builtin.rb +70 -0
  60. data/lib/textus/hooks/dispatcher.rb +49 -0
  61. data/lib/textus/hooks/loader.rb +26 -0
  62. data/lib/textus/hooks/registry.rb +73 -0
  63. data/lib/textus/init.rb +13 -10
  64. data/lib/textus/intro.rb +14 -16
  65. data/lib/textus/key/distance.rb +55 -0
  66. data/lib/textus/key/grammar.rb +33 -0
  67. data/lib/textus/key/path.rb +17 -0
  68. data/lib/textus/manifest/entry.rb +199 -0
  69. data/lib/textus/manifest.rb +10 -34
  70. data/lib/textus/migrate_keys.rb +1 -1
  71. data/lib/textus/projection.rb +5 -4
  72. data/lib/textus/proposal.rb +1 -1
  73. data/lib/textus/refresh.rb +11 -11
  74. data/lib/textus/schema/tools.rb +89 -0
  75. data/lib/textus/store/audit_log.rb +71 -0
  76. data/lib/textus/store/mover.rb +19 -16
  77. data/lib/textus/store/reader.rb +67 -0
  78. data/lib/textus/store/staleness.rb +10 -19
  79. data/lib/textus/store/validator.rb +11 -8
  80. data/lib/textus/store/view.rb +29 -0
  81. data/lib/textus/store/writer.rb +132 -0
  82. data/lib/textus/store.rb +25 -221
  83. data/lib/textus/version.rb +1 -1
  84. data/lib/textus.rb +14 -67
  85. metadata +73 -40
  86. data/lib/textus/audit_log.rb +0 -67
  87. data/lib/textus/builtin_actions.rb +0 -68
  88. data/lib/textus/cli/accept.rb +0 -13
  89. data/lib/textus/cli/action.rb +0 -51
  90. data/lib/textus/cli/build.rb +0 -11
  91. data/lib/textus/cli/delete.rb +0 -14
  92. data/lib/textus/cli/deprecated_alias.rb +0 -31
  93. data/lib/textus/cli/deps.rb +0 -10
  94. data/lib/textus/cli/doctor.rb +0 -13
  95. data/lib/textus/cli/extension_group.rb +0 -9
  96. data/lib/textus/cli/extensions.rb +0 -49
  97. data/lib/textus/cli/get.rb +0 -10
  98. data/lib/textus/cli/init.rb +0 -12
  99. data/lib/textus/cli/intro.rb +0 -9
  100. data/lib/textus/cli/key_group.rb +0 -10
  101. data/lib/textus/cli/list.rb +0 -12
  102. data/lib/textus/cli/migrate.rb +0 -41
  103. data/lib/textus/cli/migrate_keys.rb +0 -19
  104. data/lib/textus/cli/mv.rb +0 -20
  105. data/lib/textus/cli/published.rb +0 -9
  106. data/lib/textus/cli/put.rb +0 -48
  107. data/lib/textus/cli/rdeps.rb +0 -10
  108. data/lib/textus/cli/refresh.rb +0 -13
  109. data/lib/textus/cli/schema.rb +0 -10
  110. data/lib/textus/cli/schema_diff.rb +0 -15
  111. data/lib/textus/cli/schema_group.rb +0 -33
  112. data/lib/textus/cli/schema_init.rb +0 -19
  113. data/lib/textus/cli/schema_migrate.rb +0 -19
  114. data/lib/textus/cli/stale.rb +0 -12
  115. data/lib/textus/cli/uid.rb +0 -15
  116. data/lib/textus/cli/where.rb +0 -10
  117. data/lib/textus/extension_registry.rb +0 -61
  118. data/lib/textus/extensions.rb +0 -33
  119. data/lib/textus/key_distance.rb +0 -53
  120. data/lib/textus/manifest_entry.rb +0 -185
  121. data/lib/textus/migrate_v2.rb +0 -27
  122. data/lib/textus/schema_tools.rb +0 -87
  123. data/lib/textus/store/events.rb +0 -31
  124. data/lib/textus/store_view.rb +0 -27
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Refresh < Verb
5
+ option :as_flag, "--as=ROLE"
6
+
7
+ def call(store)
8
+ key = positional.shift or raise UsageError.new("refresh requires a key")
9
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
+ emit(Textus::Refresh.call(store, key, as: role))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,12 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Schema < Verb
5
+ def call(store)
6
+ key = positional.shift or raise UsageError.new("schema requires a key")
7
+ emit(store.schema_envelope(key))
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class SchemaDiff < Verb
5
+ def call(store)
6
+ name = positional.shift or raise UsageError.new("schema diff NAME")
7
+ emit(Textus::Schema::Tools.diff(store, name: name))
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class SchemaInit < Verb
5
+ option :from_key, "--from=KEY"
6
+
7
+ def call(store)
8
+ name = positional.shift or raise UsageError.new("schema init NAME")
9
+ raise UsageError.new("schema init requires --from=KEY") unless from_key
10
+
11
+ emit(Textus::Schema::Tools.init(store, name: name, from: from_key))
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class SchemaMigrate < Verb
5
+ option :rename, "--rename=O:N"
6
+
7
+ def call(store)
8
+ name = positional.shift or raise UsageError.new("schema migrate NAME")
9
+ raise UsageError.new("schema migrate requires --rename=OLD:NEW") unless rename
10
+
11
+ emit(Textus::Schema::Tools.migrate(store, name: name, rename: rename))
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Stale < Verb
5
+ option :prefix, "--prefix=KEY"
6
+ option :zone, "--zone=Z"
7
+
8
+ def call(store)
9
+ emit(store.stale(prefix: prefix, zone: zone))
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,12 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Uid < Verb
5
+ def call(store)
6
+ key = positional.shift or raise UsageError.new("uid requires a key")
7
+ emit({ "key" => key, "uid" => store.uid(key) })
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ module Textus
2
+ class CLI
3
+ class Verb
4
+ class Where < Verb
5
+ def call(store)
6
+ key = positional.shift or raise UsageError.new("where requires a key")
7
+ emit(store.where(key))
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
data/lib/textus/cli.rb CHANGED
@@ -6,40 +6,26 @@ module Textus
6
6
  # verb name → Verb subclass. Adding a new verb is a one-line entry here
7
7
  # plus a new file under lib/textus/cli/.
8
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,
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,
36
27
  }.freeze
37
28
 
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
-
43
29
  def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
44
30
  new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
45
31
  end
@@ -64,7 +50,7 @@ module Textus
64
50
  0
65
51
  else
66
52
  klass = VERBS[verb] or raise UsageError.new("unknown verb: #{verb}")
67
- dispatch(klass, argv, deprecated_alias: DEPRECATED_ALIASES.include?(verb))
53
+ dispatch(klass, argv)
68
54
  end
69
55
  rescue Textus::Error => e
70
56
  emit_error(e)
@@ -76,9 +62,8 @@ module Textus
76
62
  @store ||= Store.discover(@cwd, root: @root_arg)
77
63
  end
78
64
 
79
- def dispatch(klass, argv, deprecated_alias: false)
65
+ def dispatch(klass, argv)
80
66
  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
67
  v.parse(argv)
83
68
  v.call(klass.needs_store? ? store : nil)
84
69
  end
@@ -92,24 +77,20 @@ module Textus
92
77
 
93
78
  def print_help
94
79
  @stdout.puts <<~HELP
95
- textus #{VERSION} — reference implementation of #{PROTOCOL} (was textus/1)
80
+ textus #{VERSION} — reference implementation of #{PROTOCOL}
96
81
 
97
82
  Usage (json output is the default; --format=json accepted for back-compat):
98
83
  textus list [--prefix=KEY] [--zone=Z]
99
84
  textus where KEY
100
85
  textus get KEY
101
- textus put KEY --stdin [--action=NAME] --as=ROLE
86
+ textus put KEY --stdin [--fetch=NAME] --as=ROLE
102
87
  textus stale [--prefix=KEY] [--zone=Z]
103
88
  textus doctor
104
89
  textus intro
105
- textus migrate v2
106
90
 
107
91
  textus key {mv,uid,migrate}
108
92
  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.
93
+ textus hook {list,run}
113
94
  HELP
114
95
  end
115
96
  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
@@ -0,0 +1,57 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module Textus
5
+ module Doctor
6
+ class Check
7
+ class Sentinels < Check
8
+ def call
9
+ out = []
10
+ dir = File.join(store.root, "sentinels")
11
+ return out unless File.directory?(dir)
12
+
13
+ Dir.glob(File.join(dir, "**", "*.textus-managed.json")).each do |sp| # rubocop:disable Metrics/BlockLength
14
+ begin
15
+ data = JSON.parse(File.read(sp))
16
+ rescue JSON::ParserError => e
17
+ out << {
18
+ "code" => "sentinel.parse_error",
19
+ "level" => "warning",
20
+ "subject" => sp,
21
+ "message" => "sentinel is not valid JSON: #{e.message}",
22
+ "fix" => "delete #{sp} and re-run 'textus build' to regenerate",
23
+ }
24
+ next
25
+ end
26
+
27
+ target = data["target"]
28
+ recorded_sha = data["sha256"]
29
+
30
+ if target.nil? || !File.exist?(target)
31
+ out << {
32
+ "code" => "sentinel.orphan",
33
+ "level" => "warning",
34
+ "subject" => sp,
35
+ "message" => "sentinel target #{target.inspect} no longer exists",
36
+ "fix" => "delete #{sp} (the published file is gone) or restore the target",
37
+ }
38
+ next
39
+ end
40
+
41
+ current_sha = Digest::SHA256.hexdigest(File.binread(target))
42
+ next if recorded_sha.nil? || current_sha == recorded_sha
43
+
44
+ out << {
45
+ "code" => "sentinel.drift",
46
+ "level" => "warning",
47
+ "subject" => target,
48
+ "message" => "published file at #{target} was modified out-of-band",
49
+ "fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
50
+ }
51
+ end
52
+ out
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class Templates < Check
5
+ def call
6
+ out = []
7
+ store.manifest.entries.each do |entry|
8
+ next if entry.template.nil?
9
+
10
+ tp = File.join(store.root, "templates", entry.template)
11
+ next if File.exist?(tp)
12
+
13
+ out << {
14
+ "code" => "template.missing",
15
+ "level" => "error",
16
+ "subject" => entry.key,
17
+ "message" => "template '#{entry.template}' not found at #{tp}",
18
+ "fix" => "create the file at #{tp} or update the entry's template: field",
19
+ }
20
+ end
21
+ out
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,34 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class UnownedSchemaFields < Check
5
+ def call
6
+ out = []
7
+ dir = File.join(store.root, "schemas")
8
+ return out unless File.directory?(dir)
9
+
10
+ Dir.glob(File.join(dir, "*.yaml")).sort.each do |sp| # rubocop:disable Lint/RedundantDirGlobSort
11
+ schema = begin
12
+ Schema.load(sp)
13
+ rescue StandardError
14
+ next
15
+ end
16
+ unowned = schema.fields.each_with_object([]) do |(name, spec), acc|
17
+ acc << name if spec.is_a?(Hash) && spec["maintained_by"].nil?
18
+ end
19
+ next if unowned.empty?
20
+
21
+ out << {
22
+ "code" => "schema.unowned_fields",
23
+ "level" => "info",
24
+ "subject" => schema.name || File.basename(sp, ".yaml"),
25
+ "message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
26
+ "fix" => "add 'maintained_by: <role>' to each field in #{sp} (optional but recommended)",
27
+ }
28
+ end
29
+ out
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,30 @@
1
+ module Textus
2
+ module Doctor
3
+ # Abstract base for a single doctor check. Each concrete check inspects
4
+ # one slice of store health and returns an array of issue hashes:
5
+ # { "code" => String, "level" => "error"|"warning"|"info",
6
+ # "subject" => String, "message" => String, "fix" => String (optional) }
7
+ class Check
8
+ # Snake-case name used in --checks flag and ALL_CHECKS list. Default
9
+ # derives from the class name; override if the SPEC name diverges.
10
+ def self.name_key
11
+ @name_key ||= name.split("::").last
12
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
13
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
14
+ .downcase
15
+ end
16
+
17
+ def initialize(store)
18
+ @store = store
19
+ end
20
+
21
+ def call
22
+ raise NotImplementedError.new("#{self.class.name}#call not implemented")
23
+ end
24
+
25
+ protected
26
+
27
+ attr_reader :store
28
+ end
29
+ end
30
+ end