textus 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da7bc5c1fbc90ea76df8adb279491b3a94b4b093f014281ca28c317366539cad
4
- data.tar.gz: 92b35dab2a95f81d93e45a467652ee840ed06617a3df587d66a170884b99b81f
3
+ metadata.gz: 37ec398fd3e4bc171cdd4de0452b16de423d899103b1b66c31411bc296260115
4
+ data.tar.gz: 47f2a2f213698adfa9f7ba8019d05b80d704aaf1de7ac6cf2a96ceb33642b966
5
5
  SHA512:
6
- metadata.gz: f84bf665d8aa002bfac744ada1e43b6993d9209f8997cafdfc441f74c758edb8ef28b5b3d45e70f8ccb58cc3c82c05a3238a40940142a35d25fd1807ed237324
7
- data.tar.gz: cd2326ee626d90217aaf9f9e91b10441d6b397af3bf3bb8c54dde00e5bfa37eca56ca7011175f58168a1b0365a484de76e9ebac3022f035833974442a73e9f1e
6
+ metadata.gz: 4cccb93db3f1e94f75a12ff4513c51d6c95177e2f530b94a8ba9220cefc9e0bce949acc187f5546a6c8c68a6ebf70e3d79a890ebe32e8c2e66831e10309ee4b0
7
+ data.tar.gz: af03c10214acc105d2d7a0921c486d926446b3f7d2763172ab6199c54322682d1a40aced0966054899067840686fedbe52cbf3e9d3714dbabae8693893149467
data/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
8
8
  (currently `textus/2`, embedded in every envelope as `protocol`). The protocol
9
9
  is additive within a major; a new major would change the wire string.
10
10
 
11
+ ## 0.10.2 — Doctor and store cleanup (2026-05-23)
12
+
13
+ Patch release. Internal cleanup: extracts `Store::Sentinel`, moves audit-log integrity into `Store::AuditLog`, surfaces previously-swallowed schema parse errors, and tidies two doctor checks. No CLI, wire-protocol, or behavioral changes for plugin authors. Sentinel JSON shape changes (repo-relative paths) are forward-compatible; legacy absolute paths are still read correctly.
14
+
15
+ ### Added
16
+
17
+ - `Textus::Store::Sentinel` value object owning the sentinel JSON shape (`source`/`target`/`sha256`/`mode`) and the on-disk path layout. Repo-relative paths on write; legacy absolute paths still accepted on read.
18
+ - `Textus::Store::AuditLog#verify_integrity` returns line-by-line integrity violations as `{lineno, reason, detail}` hashes.
19
+ - `Textus::Schema#unowned_fields` returns field names whose spec lacks `maintained_by`.
20
+ - New doctor check `schema_parse_error` (error level) surfaces YAML parse failures on `schemas/*.yaml`. Previously these were silently rescued in `UnownedSchemaFields`, leaving operators with no signal.
21
+
22
+ ### Changed
23
+
24
+ - `Infra::Publisher` delegates sentinel I/O to `Store::Sentinel`. The sentinel JSON now stores repo-relative `source`/`target` so example trees can be committed without leaking author paths.
25
+ - `Doctor::Check::Sentinels` delegates parse/orphan/drift detection to `Store::Sentinel`. Drops `rubocop:disable Metrics/BlockLength`.
26
+ - `Doctor::Check::AuditLog` delegates parsing to `Store::AuditLog#verify_integrity`. Drops `rubocop:disable Metrics/BlockLength`.
27
+ - `Doctor::Check::ManifestFiles` uses `Textus::Key::Path.resolve` instead of reimplementing leaf-path math.
28
+ - `Doctor::Check::UnownedSchemaFields` uses `Schema#unowned_fields` instead of reaching into `schema.fields` and the raw `maintained_by` Hash key.
29
+ - `examples/claude-plugin/.gitignore` no longer excludes `.textus/sentinels/`. The example's sentinels are now committed with repo-relative paths.
30
+
31
+ ### Documentation
32
+
33
+ - `SPEC.md` builtin doctor-check list updated to include `schema_parse_error`, and brings the prose up to date with three checks shipped in 0.9.x/0.10.0 that were missing from the list (`policy_ambiguity`, `handler_allowlist`, `legacy_intake_fields`).
34
+
11
35
  ## 0.10.1 — Documentation refresh and spec hygiene (2026-05-22)
12
36
 
13
37
  Lightweight maintenance release: documentation refresh plus spec-suite hygiene. No `lib/` changes; no CLI, wire-protocol, or behavioral changes.
data/SPEC.md CHANGED
@@ -693,7 +693,7 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
693
693
 
694
694
  ## 10.2 `textus doctor`
695
695
 
696
- `textus doctor` returns a health-check envelope: `{ "protocol": "textus/2", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `manifest_files`, `schemas`, `templates`, `hooks`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`. Additional registered `:check` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
696
+ `textus doctor` returns a health-check envelope: `{ "protocol": "textus/2", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `manifest_files`, `schemas`, `schema_parse_error`, `templates`, `hooks`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`, `policy_ambiguity`, `handler_allowlist`, `legacy_intake_fields`. Additional registered `:check` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
697
697
 
698
698
  ## 11. Versioning
699
699
 
@@ -1,48 +1,32 @@
1
- require "json"
2
-
3
1
  module Textus
4
2
  module Doctor
5
3
  class Check
6
4
  class AuditLog < Check
7
5
  def call
8
- out = []
9
6
  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?
7
+ Textus::Store::AuditLog.new(store.root).verify_integrity.map do |v|
8
+ {
9
+ "code" => "audit.parse_error",
10
+ "level" => "warning",
11
+ "subject" => "#{path}:#{v["lineno"]}",
12
+ "message" => violation_message(v),
13
+ "fix" => "inspect #{path} at line #{v["lineno"]} and remove the corrupted row",
14
+ }
15
+ end
16
+ end
15
17
 
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
18
+ private
34
19
 
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
20
+ def violation_message(v)
21
+ case v["reason"]
22
+ when "invalid_json"
23
+ "audit log line #{v["lineno"]} is invalid JSON: #{v["detail"]}"
24
+ when "short_tsv"
25
+ "audit log line #{v["lineno"]} #{v["detail"]} " \
26
+ "(consider migrating to NDJSON)"
27
+ else
28
+ v["detail"]
44
29
  end
45
- out
46
30
  end
47
31
  end
48
32
  end
@@ -3,11 +3,10 @@ module Textus
3
3
  class Check
4
4
  class ManifestFiles < Check
5
5
  def call
6
- out = []
7
- store.manifest.entries.each do |entry|
6
+ store.manifest.entries.each_with_object([]) do |entry, out|
8
7
  next if entry.nested
9
8
 
10
- path = leaf_path_for(entry)
9
+ path = Textus::Key::Path.resolve(store.manifest, entry)
11
10
  next if File.exist?(path)
12
11
 
13
12
  out << {
@@ -19,18 +18,6 @@ module Textus
19
18
  "(or leave empty if not yet authored)",
20
19
  }
21
20
  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
21
  end
35
22
  end
36
23
  end
@@ -0,0 +1,28 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # Surfaces YAML parse failures for files in <store>/schemas/. Without
5
+ # this check, malformed schemas are silently skipped by other doctor
6
+ # checks (UnownedSchemaFields rescues, Schemas only checks filenames),
7
+ # leaving the operator with no signal that a schema is broken.
8
+ class SchemaParseError < Check
9
+ def call
10
+ dir = File.join(store.root, "schemas")
11
+ return [] unless File.directory?(dir)
12
+
13
+ Dir.glob(File.join(dir, "*.yaml")).each_with_object([]) do |path, out|
14
+ Schema.load(path)
15
+ rescue StandardError => e
16
+ out << {
17
+ "code" => "schema.parse_error",
18
+ "level" => "error",
19
+ "subject" => path,
20
+ "message" => "schema failed to parse: #{e.class}: #{e.message}",
21
+ "fix" => "fix the YAML at #{path} (check indentation, quoted scalars, and aliases)",
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,55 +1,56 @@
1
- require "digest"
2
- require "json"
3
-
4
1
  module Textus
5
2
  module Doctor
6
3
  class Check
7
4
  class Sentinels < Check
8
5
  def call
9
- out = []
10
6
  dir = File.join(store.root, "sentinels")
11
- return out unless File.directory?(dir)
7
+ return [] unless File.directory?(dir)
12
8
 
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
9
+ repo_root = File.dirname(store.root)
10
+ Dir.glob(File.join(dir, "**", "*#{Textus::Store::Sentinel::SUFFIX}")).flat_map do |sentinel_path|
11
+ inspect_sentinel(sentinel_path, repo_root)
12
+ end
13
+ end
26
14
 
27
- target = data["target"]
28
- recorded_sha = data["sha256"]
15
+ private
29
16
 
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
17
+ def inspect_sentinel(sentinel_path, repo_root)
18
+ sentinel = Textus::Store::Sentinel.load(sentinel_path, repo_root)
19
+ return [parse_error_issue(sentinel_path)] if sentinel.nil?
20
+ return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?
21
+ return [drift_issue(sentinel)] if sentinel.drift?
40
22
 
41
- current_sha = Digest::SHA256.hexdigest(File.binread(target))
42
- next if recorded_sha.nil? || current_sha == recorded_sha
23
+ []
24
+ end
43
25
 
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
26
+ def parse_error_issue(sentinel_path)
27
+ {
28
+ "code" => "sentinel.parse_error",
29
+ "level" => "warning",
30
+ "subject" => sentinel_path,
31
+ "message" => "sentinel is not valid JSON",
32
+ "fix" => "delete #{sentinel_path} and re-run 'textus build' to regenerate",
33
+ }
34
+ end
35
+
36
+ def orphan_issue(sentinel_path, sentinel)
37
+ {
38
+ "code" => "sentinel.orphan",
39
+ "level" => "warning",
40
+ "subject" => sentinel_path,
41
+ "message" => "sentinel target #{sentinel.target.inspect} no longer exists",
42
+ "fix" => "delete #{sentinel_path} (the published file is gone) or restore the target",
43
+ }
44
+ end
45
+
46
+ def drift_issue(sentinel)
47
+ {
48
+ "code" => "sentinel.drift",
49
+ "level" => "warning",
50
+ "subject" => sentinel.target,
51
+ "message" => "published file at #{sentinel.target} was modified out-of-band",
52
+ "fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
53
+ }
53
54
  end
54
55
  end
55
56
  end
@@ -3,30 +3,36 @@ module Textus
3
3
  class Check
4
4
  class UnownedSchemaFields < Check
5
5
  def call
6
- out = []
7
6
  dir = File.join(store.root, "schemas")
8
- return out unless File.directory?(dir)
7
+ return [] unless File.directory?(dir)
9
8
 
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
- }
9
+ Dir.glob(File.join(dir, "*.yaml")).flat_map do |path|
10
+ issues_for(path)
28
11
  end
29
- out
12
+ end
13
+
14
+ private
15
+
16
+ def issues_for(path)
17
+ schema = safe_load(path)
18
+ return [] if schema.nil?
19
+
20
+ unowned = schema.unowned_fields
21
+ return [] if unowned.empty?
22
+
23
+ [{
24
+ "code" => "schema.unowned_fields",
25
+ "level" => "info",
26
+ "subject" => schema.name || File.basename(path, ".yaml"),
27
+ "message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
28
+ "fix" => "add 'maintained_by: <role>' to each field in #{path} (optional but recommended)",
29
+ }]
30
+ end
31
+
32
+ def safe_load(path)
33
+ Schema.load(path)
34
+ rescue StandardError
35
+ nil
30
36
  end
31
37
  end
32
38
  end
data/lib/textus/doctor.rb CHANGED
@@ -11,6 +11,7 @@ module Textus
11
11
  CHECKS = [
12
12
  Check::ManifestFiles,
13
13
  Check::Schemas,
14
+ Check::SchemaParseError,
14
15
  Check::Templates,
15
16
  Check::Hooks,
16
17
  Check::IntakeRegistration,
@@ -1,24 +1,21 @@
1
- require "json"
2
- require "digest"
3
1
  require "fileutils"
4
2
 
5
3
  module Textus
6
4
  module Infra
7
5
  # Publishes built artifacts from the store to repo-relative consumer paths.
8
6
  # Publish = copy + sentinel. The in-store file is already the consumer-shaped
9
- # artifact; no parsing or stripping. Sentinels live under
7
+ # artifact; no parsing or stripping.
8
+ #
9
+ # Sentinel I/O is delegated to Store::Sentinel. Sentinels live under
10
10
  # `<store_root>/sentinels/` and mirror the target's repo-relative layout so
11
11
  # consumer directories aren't polluted with `.textus-managed.json` siblings.
12
12
  module Publisher
13
- SENTINEL_SUFFIX = ".textus-managed.json".freeze
14
- SENTINEL_DIR = "sentinels".freeze
15
-
16
13
  def self.publish(source:, target:, store_root:)
17
14
  FileUtils.mkdir_p(File.dirname(target))
18
15
  refuse_if_unmanaged(target, store_root)
19
16
  File.delete(target) if File.symlink?(target)
20
17
  FileUtils.cp(source, target)
21
- write_sentinel(target, store_root: store_root, source: source)
18
+ Store::Sentinel.write!(target: target, source: source, store_root: store_root)
22
19
  cleanup_legacy_sentinel(target)
23
20
  end
24
21
 
@@ -30,43 +27,12 @@ module Textus
30
27
  end
31
28
 
32
29
  def self.managed?(target, store_root)
33
- File.exist?(sentinel_path(target, store_root)) || File.exist?(legacy_sentinel_path(target))
34
- end
35
-
36
- def self.write_sentinel(target, store_root:, source:)
37
- path = sentinel_path(target, store_root)
38
- FileUtils.mkdir_p(File.dirname(path))
39
- File.write(path, JSON.generate(
40
- "source" => source,
41
- "target" => target,
42
- "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
43
- "mode" => "copy",
44
- ))
45
- end
46
-
47
- # Sentinel layout: <store_root>/sentinels/<target_rel_to_repo>.textus-managed.json
48
- # The full target extension is preserved so a marketplace.json and
49
- # marketplace.yaml don't collide.
50
- def self.sentinel_path(target, store_root)
51
- repo_root = File.dirname(store_root)
52
- rel = relative_to(target, repo_root) || File.basename(target)
53
- File.join(store_root, SENTINEL_DIR, rel + SENTINEL_SUFFIX)
54
- end
55
-
56
- def self.legacy_sentinel_path(target)
57
- target + SENTINEL_SUFFIX
30
+ File.exist?(Store::Sentinel.sentinel_path(target, store_root)) ||
31
+ File.exist?(Store::Sentinel.legacy_path(target))
58
32
  end
59
33
 
60
34
  def self.cleanup_legacy_sentinel(target)
61
- FileUtils.rm_f(legacy_sentinel_path(target))
62
- end
63
-
64
- def self.relative_to(path, base)
65
- path = File.expand_path(path)
66
- base = File.expand_path(base)
67
- return nil unless path.start_with?(base + File::SEPARATOR)
68
-
69
- path[(base.length + 1)..]
35
+ FileUtils.rm_f(Store::Sentinel.legacy_path(target))
70
36
  end
71
37
  end
72
38
  end
data/lib/textus/schema.rb CHANGED
@@ -26,6 +26,16 @@ module Textus
26
26
  meta["maintained_by"]
27
27
  end
28
28
 
29
+ # Returns the list of field names whose spec is a Hash but lacks the
30
+ # 'maintained_by' key. Used by Doctor::Check::UnownedSchemaFields.
31
+ def unowned_fields
32
+ @fields.each_with_object([]) do |(name, spec), acc|
33
+ next unless spec.is_a?(Hash)
34
+
35
+ acc << name if spec["maintained_by"].nil?
36
+ end
37
+ end
38
+
29
39
  def evolution
30
40
  raw = @raw["evolution"] || {}
31
41
  raw.each_with_object({}) do |(k, v), h|
@@ -47,6 +47,20 @@ module Textus
47
47
  end
48
48
  end
49
49
 
50
+ # Returns an array of integrity-violation descriptors for the on-disk log.
51
+ # Each entry is { "lineno" => Integer, "reason" => String, "detail" => String }.
52
+ # Empty array means the log is well-formed (or doesn't exist yet).
53
+ def verify_integrity
54
+ return [] unless File.exist?(@path)
55
+
56
+ out = []
57
+ File.foreach(@path).with_index(1) do |line, lineno|
58
+ violation = check_line(line.chomp, lineno)
59
+ out << violation if violation
60
+ end
61
+ out
62
+ end
63
+
50
64
  private
51
65
 
52
66
  def parse_row(line)
@@ -66,6 +80,30 @@ module Textus
66
80
  rescue JSON::ParserError
67
81
  nil
68
82
  end
83
+
84
+ def check_line(stripped, lineno)
85
+ return nil if stripped.empty?
86
+
87
+ if stripped.start_with?("{")
88
+ begin
89
+ JSON.parse(stripped)
90
+ nil
91
+ rescue JSON::ParserError => e
92
+ { "lineno" => lineno, "reason" => "invalid_json", "detail" => e.message }
93
+ end
94
+ else
95
+ # parse_row accepts >= 4 fields for read-compat; integrity requires
96
+ # all 6 data columns of the legacy TSV format.
97
+ fields = stripped.split("\t")
98
+ return nil if fields.length >= 6
99
+
100
+ {
101
+ "lineno" => lineno,
102
+ "reason" => "short_tsv",
103
+ "detail" => "legacy TSV row has #{fields.length} fields (expected >= 6)",
104
+ }
105
+ end
106
+ end
69
107
  end
70
108
  end
71
109
  end
@@ -0,0 +1,93 @@
1
+ require "json"
2
+ require "digest"
3
+ require "fileutils"
4
+
5
+ module Textus
6
+ class Store
7
+ # Value object for sentinel files written by Infra::Publisher and inspected
8
+ # by Doctor::Check::Sentinels. Owns the JSON shape ({source, target,
9
+ # sha256, mode}) and the on-disk path layout (<store_root>/sentinels/
10
+ # <target-rel-to-repo>.textus-managed.json).
11
+ #
12
+ # Repo-relative target/source on write so example trees can be committed
13
+ # without leaking the author's absolute filesystem paths. Legacy absolute
14
+ # paths are still accepted on read.
15
+ class Sentinel
16
+ SUFFIX = ".textus-managed.json".freeze
17
+ DIR = "sentinels".freeze
18
+
19
+ attr_reader :target, :source, :sha256, :mode
20
+
21
+ def self.write!(target:, source:, store_root:)
22
+ path = sentinel_path(target, store_root)
23
+ FileUtils.mkdir_p(File.dirname(path))
24
+ repo_root = File.dirname(store_root)
25
+ File.write(path, JSON.generate(
26
+ "source" => rel_or_abs(source, repo_root),
27
+ "target" => rel_or_abs(target, repo_root),
28
+ "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
29
+ "mode" => "copy",
30
+ ))
31
+ end
32
+
33
+ def self.load(path, repo_root)
34
+ raw = JSON.parse(File.read(path))
35
+ new(
36
+ target: absolutize(raw["target"], repo_root),
37
+ source: absolutize(raw["source"], repo_root),
38
+ sha256: raw["sha256"],
39
+ mode: raw["mode"],
40
+ )
41
+ rescue JSON::ParserError, Errno::ENOENT
42
+ nil
43
+ end
44
+
45
+ def self.sentinel_path(target, store_root)
46
+ repo_root = File.dirname(store_root)
47
+ rel = relative_to(target, repo_root) || File.basename(target)
48
+ File.join(store_root, DIR, rel + SUFFIX)
49
+ end
50
+
51
+ def self.legacy_path(target)
52
+ target + SUFFIX
53
+ end
54
+
55
+ def self.rel_or_abs(path, repo_root)
56
+ relative_to(path, repo_root) || File.expand_path(path)
57
+ end
58
+
59
+ def self.relative_to(path, repo_root)
60
+ path = File.expand_path(path)
61
+ base = File.expand_path(repo_root)
62
+ return nil unless path.start_with?(base + File::SEPARATOR)
63
+
64
+ path[(base.length + 1)..]
65
+ end
66
+
67
+ def self.absolutize(path, repo_root)
68
+ return path if path.nil?
69
+ return path if File.absolute_path?(path)
70
+
71
+ File.expand_path(path, repo_root)
72
+ end
73
+
74
+ def initialize(target:, source:, sha256:, mode:)
75
+ @target = target
76
+ @source = source
77
+ @sha256 = sha256
78
+ @mode = mode
79
+ end
80
+
81
+ def orphan?
82
+ @target.nil? || !File.exist?(@target)
83
+ end
84
+
85
+ def drift?
86
+ return false if orphan?
87
+ return false if @sha256.nil?
88
+
89
+ Digest::SHA256.hexdigest(File.binread(@target)) != @sha256
90
+ end
91
+ end
92
+ end
93
+ end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.10.1"
2
+ VERSION = "0.10.2"
3
3
  PROTOCOL = "textus/2"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.1
4
+ version: 0.10.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -178,6 +178,7 @@ files:
178
178
  - lib/textus/doctor/check/legacy_intake_fields.rb
179
179
  - lib/textus/doctor/check/manifest_files.rb
180
180
  - lib/textus/doctor/check/policy_ambiguity.rb
181
+ - lib/textus/doctor/check/schema_parse_error.rb
181
182
  - lib/textus/doctor/check/schema_violations.rb
182
183
  - lib/textus/doctor/check/schemas.rb
183
184
  - lib/textus/doctor/check/sentinels.rb
@@ -233,6 +234,7 @@ files:
233
234
  - lib/textus/store/audit_log.rb
234
235
  - lib/textus/store/mover.rb
235
236
  - lib/textus/store/reader.rb
237
+ - lib/textus/store/sentinel.rb
236
238
  - lib/textus/store/staleness.rb
237
239
  - lib/textus/store/validator.rb
238
240
  - lib/textus/store/writer.rb