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 +4 -4
- data/CHANGELOG.md +24 -0
- data/SPEC.md +1 -1
- data/lib/textus/doctor/check/audit_log.rb +20 -36
- data/lib/textus/doctor/check/manifest_files.rb +2 -15
- data/lib/textus/doctor/check/schema_parse_error.rb +28 -0
- data/lib/textus/doctor/check/sentinels.rb +42 -41
- data/lib/textus/doctor/check/unowned_schema_fields.rb +27 -21
- data/lib/textus/doctor.rb +1 -0
- data/lib/textus/infra/publisher.rb +7 -41
- data/lib/textus/schema.rb +10 -0
- data/lib/textus/store/audit_log.rb +38 -0
- data/lib/textus/store/sentinel.rb +93 -0
- data/lib/textus/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37ec398fd3e4bc171cdd4de0452b16de423d899103b1b66c31411bc296260115
|
|
4
|
+
data.tar.gz: 47f2a2f213698adfa9f7ba8019d05b80d704aaf1de7ac6cf2a96ceb33642b966
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
7
|
+
return [] unless File.directory?(dir)
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
28
|
-
recorded_sha = data["sha256"]
|
|
15
|
+
private
|
|
29
16
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
43
25
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
7
|
+
return [] unless File.directory?(dir)
|
|
9
8
|
|
|
10
|
-
Dir.glob(File.join(dir, "*.yaml")).
|
|
11
|
-
|
|
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
|
-
|
|
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
|
@@ -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.
|
|
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
|
-
|
|
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)) ||
|
|
34
|
-
|
|
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(
|
|
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
|
data/lib/textus/version.rb
CHANGED
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.
|
|
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
|