kairos-chain 2.0.5 → 2.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7256c06e2fd204672e04f33e2b88bed3f302188b6d97cb5fe736e142b84580bd
4
- data.tar.gz: c95abbd55c084474595a81e257eee4a5ad08168b47c2d069669dfe8ca49c7ed7
3
+ metadata.gz: bb6f9b51791860a4898475d255ec105e4de84ac49cdbb8efb22297fff9db3337
4
+ data.tar.gz: 9c13b5e3743eeb39d84fa037f84027a0be9865d75ba5851123fe57fdd21133f6
5
5
  SHA512:
6
- metadata.gz: 9552b83a0ebb90e312837ddad5b0086d620be6c92a2e83fdb93d0cec6eb043dcc6b6f37254e52cb2dc9d6e276d7154a9256360487e45c0cf41c25597bd202e4c
7
- data.tar.gz: 40636983d68a243b1ba7d7907c59550f6ec15e35ba234f6cd975a6b539f6a416b4da973388310e3bd877cc32ffe25e196ae223aeb736a027330b4a8325eab13a
6
+ metadata.gz: 955cca06fa60a00c344412a98e73dd47c9164a122fcb185c72c27a9bcc948691477835b69ba51974a5e4702451b7264c61f6627d5791fb3657ecdb817a29af0d
7
+ data.tar.gz: 105ac2714d16387a47392a20e9fb707034702829abb071cd03f0be4bcf42896bec655ecede56236b4965ddc0ddb140227bcea137a3ca140bddcb2cee7ac0e582
data/CHANGELOG.md CHANGED
@@ -4,12 +4,38 @@ All notable changes to the `kairos-chain` gem will be documented in this file.
4
4
 
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
- ## [2.0.5] - 2026-02-25
7
+ ## [2.1.0] - 2026-02-25
8
+
9
+ ### Added
10
+
11
+ - **Phase 1: DSL/AST Partial Formalization Layer**
12
+ - `AstNode` Struct and `DefinitionContext` with 5 node types (Constraint, SemanticReasoning, Plan, ToolCall, Check)
13
+ - `Skill` Struct extended with `:definition` and `:formalization_notes` fields
14
+ - `FormalizationDecision` class for on-chain provenance records
15
+ - `formalization_record` MCP tool: record formalization decisions to blockchain
16
+ - `formalization_history` MCP tool: query accumulated formalization decisions
17
+ - `skills_dsl_get` enhanced with Definition (Structural Layer) and Formalization Notes (Provenance Layer) sections
18
+ - `core_safety` and `evolution_rules` L0 skills annotated with definition blocks
19
+ - 68 tests (test_dsl_ast_phase1.rb)
20
+
21
+ - **Phase 2: AST Verification Engine, Decompiler, and Drift Detection**
22
+ - `AstEngine`: Pattern-matched structural verification (no eval) with condition evaluation (==, <, >=, .method?(), not in)
23
+ - `Decompiler`: AST to human-readable Markdown reconstruction
24
+ - `DriftDetector`: Content/definition layer divergence detection with coverage_ratio and keyword matching (no LLM)
25
+ - `definition_verify` MCP tool: structural constraint verification report
26
+ - `definition_decompile` MCP tool: reverse AST to human-readable form
27
+ - `definition_drift` MCP tool: detect content/definition layer misalignment
28
+ - `skills_dsl_get` enhanced with Verification Status section (with fallback)
29
+ - Security: method call whitelist for condition evaluation, type-safe numeric comparisons
30
+ - 91 tests (test_dsl_ast_phase2.rb), full backward compatibility with Phase 1
31
+
32
+ - **DSL/AST Source of Truth Policy**: Ruby DSL (.rb) is authoritative; JSON representations are derived outputs
33
+
34
+ - **Upgrade notification at MCP session start**: `Protocol#handle_initialize` checks gem vs. data version via `UpgradeAnalyzer` and returns a `notifications` entry when an upgrade is available, so LLM clients are informed at session start without disrupting normal operation
8
35
 
9
36
  ### Fixed
10
37
 
11
- - **Deadlock**: `PendingChanges#summary` caused `deadlock; recursive locking` by calling `includes_l0_change?`, `includes_promotion?`, `includes_demotion?` from within a `@mutex.synchronize` block. Separated lock acquisition (public API) from logic (private `_`-prefixed methods).
12
- - **Atomicity**: `check_trigger_conditions` now executes within a single lock, preventing race conditions between individual predicate checks.
38
+ - **Deadlock in PendingChanges#summary**: Recursive locking when summary calls constraint check methods within already-held @mutex synchronize block. Separated lock acquisition (public API) from logic (private methods). Made check_trigger_conditions atomic.
13
39
 
14
40
  ---
15
41
 
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../skill_contexts'
4
+
5
+ module KairosMcp
6
+ module DslAst
7
+ # Result of evaluating a single AST node
8
+ NodeResult = Struct.new(:node_name, :node_type, :satisfied, :detail, :evaluable, keyword_init: true)
9
+
10
+ # Result of verifying all definition nodes for a skill
11
+ VerificationReport = Struct.new(:skill_id, :results, :timestamp, keyword_init: true) do
12
+ # All deterministic (evaluable) nodes passed?
13
+ def all_deterministic_passed?
14
+ results.select(&:evaluable).all? { |r| r.satisfied == true }
15
+ end
16
+
17
+ # Nodes that require human judgment
18
+ def human_required
19
+ results.reject(&:evaluable)
20
+ end
21
+
22
+ # Summary counts
23
+ def summary
24
+ total = results.size
25
+ passed = results.count { |r| r.evaluable && r.satisfied == true }
26
+ failed = results.count { |r| r.evaluable && r.satisfied == false }
27
+ unknown = results.count { |r| r.evaluable && r.satisfied == :unknown }
28
+ non_evaluable = results.count { |r| !r.evaluable }
29
+ { total: total, passed: passed, failed: failed, unknown: unknown, human_required: non_evaluable }
30
+ end
31
+ end
32
+
33
+ # AST node evaluation engine
34
+ # Verifies definition nodes against a binding context without using eval.
35
+ # Design: verification-only — does not replace behavior block execution.
36
+ # NOTE: The engine itself is infrastructure in Phase 2.
37
+ # Policy aspects (drift thresholds, etc.) may become a SkillSet in Phase 3.
38
+ class AstEngine
39
+ # Allowed methods for condition evaluation via send().
40
+ # Only query methods (no side effects) are permitted.
41
+ ALLOWED_METHODS = %i[
42
+ can_evolve? has_tool? include? key? empty? nil?
43
+ is_a? respond_to? size length count
44
+ ].freeze
45
+
46
+ # Verify all definition nodes for a skill
47
+ # @param skill [KairosMcp::SkillsDsl::Skill] the skill to verify
48
+ # @param binding_context [Hash] runtime values for condition evaluation
49
+ # @return [VerificationReport]
50
+ def self.verify(skill, binding_context: {})
51
+ return nil unless skill.definition
52
+
53
+ results = skill.definition.nodes.map do |node|
54
+ evaluate_node(node, binding_context: binding_context)
55
+ end
56
+
57
+ VerificationReport.new(
58
+ skill_id: skill.id,
59
+ results: results,
60
+ timestamp: Time.now.iso8601
61
+ )
62
+ end
63
+
64
+ # Evaluate a single AST node
65
+ # @param node [KairosMcp::AstNode] the node to evaluate
66
+ # @param binding_context [Hash] runtime values for condition evaluation
67
+ # @return [NodeResult]
68
+ def self.evaluate_node(node, binding_context: {})
69
+ case node.type
70
+ when :Constraint
71
+ evaluate_constraint(node, binding_context)
72
+ when :Check
73
+ evaluate_check(node, binding_context)
74
+ when :Plan
75
+ evaluate_plan(node)
76
+ when :ToolCall
77
+ evaluate_tool_call(node)
78
+ when :SemanticReasoning
79
+ evaluate_semantic_reasoning(node)
80
+ else
81
+ NodeResult.new(
82
+ node_name: node.name,
83
+ node_type: node.type,
84
+ satisfied: :unknown,
85
+ detail: "Unknown node type: #{node.type}",
86
+ evaluable: false
87
+ )
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ # Evaluate Constraint node via pattern matching (no eval)
94
+ def self.evaluate_constraint(node, binding_context)
95
+ opts = node.options || {}
96
+ condition = opts[:condition]
97
+
98
+ if condition.nil?
99
+ # No condition string — check if required key exists in context
100
+ if opts[:required] == true
101
+ # Constraint is a declaration; structurally valid
102
+ return NodeResult.new(
103
+ node_name: node.name,
104
+ node_type: :Constraint,
105
+ satisfied: true,
106
+ detail: "Required constraint declared (structural)",
107
+ evaluable: true
108
+ )
109
+ end
110
+
111
+ return NodeResult.new(
112
+ node_name: node.name,
113
+ node_type: :Constraint,
114
+ satisfied: true,
115
+ detail: "Constraint declared (no condition to evaluate)",
116
+ evaluable: true
117
+ )
118
+ end
119
+
120
+ # Pattern match the condition string
121
+ result = match_condition(condition, binding_context)
122
+ NodeResult.new(
123
+ node_name: node.name,
124
+ node_type: :Constraint,
125
+ satisfied: result[:satisfied],
126
+ detail: result[:detail],
127
+ evaluable: result[:evaluable]
128
+ )
129
+ end
130
+
131
+ # Evaluate Check node
132
+ def self.evaluate_check(node, binding_context)
133
+ opts = node.options || {}
134
+ condition = opts[:condition]
135
+
136
+ unless condition
137
+ return NodeResult.new(
138
+ node_name: node.name,
139
+ node_type: :Check,
140
+ satisfied: :unknown,
141
+ detail: "No condition specified",
142
+ evaluable: false
143
+ )
144
+ end
145
+
146
+ result = match_condition(condition, binding_context)
147
+ NodeResult.new(
148
+ node_name: node.name,
149
+ node_type: :Check,
150
+ satisfied: result[:satisfied],
151
+ detail: result[:detail],
152
+ evaluable: result[:evaluable]
153
+ )
154
+ end
155
+
156
+ # Evaluate Plan node — structural validity (steps exist and are named)
157
+ def self.evaluate_plan(node)
158
+ opts = node.options || {}
159
+ steps = opts[:steps]
160
+
161
+ unless steps.is_a?(Array) && !steps.empty?
162
+ return NodeResult.new(
163
+ node_name: node.name,
164
+ node_type: :Plan,
165
+ satisfied: false,
166
+ detail: "Plan has no steps defined",
167
+ evaluable: true
168
+ )
169
+ end
170
+
171
+ NodeResult.new(
172
+ node_name: node.name,
173
+ node_type: :Plan,
174
+ satisfied: true,
175
+ detail: "Plan has #{steps.size} steps: #{steps.map(&:to_s).join(' -> ')}",
176
+ evaluable: true
177
+ )
178
+ end
179
+
180
+ # Evaluate ToolCall node — command recognition (does not execute)
181
+ def self.evaluate_tool_call(node)
182
+ opts = node.options || {}
183
+ command = opts[:command]
184
+
185
+ unless command && !command.to_s.strip.empty?
186
+ return NodeResult.new(
187
+ node_name: node.name,
188
+ node_type: :ToolCall,
189
+ satisfied: false,
190
+ detail: "No command specified",
191
+ evaluable: true
192
+ )
193
+ end
194
+
195
+ NodeResult.new(
196
+ node_name: node.name,
197
+ node_type: :ToolCall,
198
+ satisfied: true,
199
+ detail: "Command recognized: #{command}",
200
+ evaluable: true
201
+ )
202
+ end
203
+
204
+ # SemanticReasoning — explicitly non-evaluable (requires human judgment)
205
+ def self.evaluate_semantic_reasoning(node)
206
+ opts = node.options || {}
207
+ prompt = opts[:prompt] || "(no prompt)"
208
+
209
+ NodeResult.new(
210
+ node_name: node.name,
211
+ node_type: :SemanticReasoning,
212
+ satisfied: :unknown,
213
+ detail: "Requires human judgment: #{prompt}",
214
+ evaluable: false
215
+ )
216
+ end
217
+
218
+ # Pattern-match a condition string against binding_context
219
+ # Supported patterns:
220
+ # "X == true/false" — boolean comparison
221
+ # "X == VALUE" — equality comparison
222
+ # "X < Y" / "X > Y" / "X >= Y" / "X <= Y" — numeric comparison
223
+ # "X.method?(arg)" — method call on context object
224
+ # "X not in Y" — exclusion check
225
+ # Unsupported patterns return evaluable: false
226
+ def self.match_condition(condition, binding_context)
227
+ # Pattern: "X == true" or "X == false"
228
+ if condition =~ /\A(\w+)\s*==\s*(true|false)\z/
229
+ var_name = $1.to_sym
230
+ expected = $2 == 'true'
231
+ if binding_context.key?(var_name)
232
+ actual = binding_context[var_name]
233
+ return {
234
+ satisfied: actual == expected,
235
+ detail: "#{var_name}: expected #{expected}, got #{actual}",
236
+ evaluable: true
237
+ }
238
+ else
239
+ return {
240
+ satisfied: :unknown,
241
+ detail: "Variable '#{var_name}' not in binding context",
242
+ evaluable: false
243
+ }
244
+ end
245
+ end
246
+
247
+ # Pattern: "X < Y" or "X > Y" or "X >= Y" or "X <= Y"
248
+ if condition =~ /\A(\w+)\s*(<|>|<=|>=)\s*(\w+)\z/
249
+ left_name = $1.to_sym
250
+ op = $2
251
+ right_name = $3.to_sym
252
+
253
+ left_val = binding_context.key?(left_name) ? binding_context[left_name] : nil
254
+ right_val = binding_context.key?(right_name) ? binding_context[right_name] : nil
255
+
256
+ if left_val.nil? || right_val.nil?
257
+ missing = []
258
+ missing << left_name unless binding_context.key?(left_name)
259
+ missing << right_name unless binding_context.key?(right_name)
260
+ return {
261
+ satisfied: :unknown,
262
+ detail: "Missing variables: #{missing.join(', ')}",
263
+ evaluable: false
264
+ }
265
+ end
266
+
267
+ begin
268
+ result = case op
269
+ when '<' then left_val < right_val
270
+ when '>' then left_val > right_val
271
+ when '<=' then left_val <= right_val
272
+ when '>=' then left_val >= right_val
273
+ end
274
+
275
+ return {
276
+ satisfied: result,
277
+ detail: "#{left_name}(#{left_val}) #{op} #{right_name}(#{right_val}) = #{result}",
278
+ evaluable: true
279
+ }
280
+ rescue TypeError, ArgumentError => e
281
+ return {
282
+ satisfied: :unknown,
283
+ detail: "Type error in comparison: #{e.message}",
284
+ evaluable: false
285
+ }
286
+ end
287
+ end
288
+
289
+ # Pattern: "X.method?(arg)"
290
+ if condition =~ /\A(\w+)\.(\w+\??)\(([^)]*)\)\z/
291
+ obj_name = $1.to_sym
292
+ method_name = $2.to_sym
293
+ arg_str = $3.strip
294
+
295
+ if binding_context.key?(obj_name)
296
+ obj = binding_context[obj_name]
297
+ if obj.respond_to?(method_name)
298
+ # Security: only allow whitelisted query methods (no side effects)
299
+ unless ALLOWED_METHODS.include?(method_name)
300
+ return {
301
+ satisfied: :unknown,
302
+ detail: "Method '#{method_name}' not in allowed list",
303
+ evaluable: false
304
+ }
305
+ end
306
+ # Parse argument: try symbol, then string
307
+ arg = arg_str.start_with?(':') ? arg_str[1..-1].to_sym : arg_str
308
+ begin
309
+ result = obj.send(method_name, arg)
310
+ return {
311
+ satisfied: !!result,
312
+ detail: "#{obj_name}.#{method_name}(#{arg_str}) = #{result}",
313
+ evaluable: true
314
+ }
315
+ rescue StandardError => e
316
+ return {
317
+ satisfied: :unknown,
318
+ detail: "Error calling #{method_name}: #{e.message}",
319
+ evaluable: false
320
+ }
321
+ end
322
+ end
323
+ end
324
+
325
+ return {
326
+ satisfied: :unknown,
327
+ detail: "Cannot evaluate: #{condition}",
328
+ evaluable: false
329
+ }
330
+ end
331
+
332
+ # Pattern: "X not in Y"
333
+ if condition =~ /\A(\w+)\s+not\s+in\s+(\w+)\z/
334
+ item_name = $1.to_sym
335
+ collection_name = $2.to_sym
336
+
337
+ if binding_context.key?(item_name) && binding_context.key?(collection_name)
338
+ item = binding_context[item_name]
339
+ collection = binding_context[collection_name]
340
+ if collection.respond_to?(:include?)
341
+ result = !collection.include?(item)
342
+ return {
343
+ satisfied: result,
344
+ detail: "#{item_name} not in #{collection_name}: #{result}",
345
+ evaluable: true
346
+ }
347
+ end
348
+ end
349
+
350
+ return {
351
+ satisfied: :unknown,
352
+ detail: "Cannot evaluate exclusion: #{condition}",
353
+ evaluable: false
354
+ }
355
+ end
356
+
357
+ # Unrecognized pattern
358
+ {
359
+ satisfied: :unknown,
360
+ detail: "Unrecognized condition pattern: #{condition}",
361
+ evaluable: false
362
+ }
363
+ end
364
+ end
365
+ end
366
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../skill_contexts'
4
+
5
+ module KairosMcp
6
+ module DslAst
7
+ # AST -> Natural language reverse conversion
8
+ # Template-based decompilation of definition nodes to human-readable Markdown.
9
+ # Used for: drift detection comparison, formalization_record decompile_text field.
10
+ class Decompiler
11
+ # Decompile a full definition to Markdown
12
+ # @param definition [KairosMcp::DefinitionContext] the definition to decompile
13
+ # @return [String] Markdown representation
14
+ def self.decompile(definition)
15
+ return "" unless definition && definition.nodes && !definition.nodes.empty?
16
+
17
+ lines = ["## Definition (Decompiled)\n"]
18
+
19
+ definition.nodes.each do |node|
20
+ lines << decompile_node(node)
21
+ end
22
+
23
+ lines.join("\n")
24
+ end
25
+
26
+ # Decompile a single node to a Markdown line
27
+ # @param node [KairosMcp::AstNode] the node to decompile
28
+ # @return [String] Markdown representation
29
+ def self.decompile_node(node)
30
+ case node.type
31
+ when :Constraint
32
+ decompile_constraint(node)
33
+ when :Check
34
+ decompile_check(node)
35
+ when :Plan
36
+ decompile_plan(node)
37
+ when :ToolCall
38
+ decompile_tool_call(node)
39
+ when :SemanticReasoning
40
+ decompile_semantic_reasoning(node)
41
+ else
42
+ "- **Unknown** (`#{node.name}`): type=#{node.type}"
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def self.decompile_constraint(node)
49
+ opts = node.options || {}
50
+ parts = ["- **Requirement** (`#{node.name}`)"]
51
+
52
+ if opts[:condition]
53
+ parts << ": #{opts[:condition]}"
54
+ end
55
+
56
+ qualifiers = []
57
+ qualifiers << "required" if opts[:required]
58
+ qualifiers << "timing: #{opts[:timing]}" if opts[:timing]
59
+ qualifiers << "scope: #{opts[:scope]}" if opts[:scope]
60
+ qualifiers << "target: #{opts[:target]}" if opts[:target]
61
+
62
+ parts << " [#{qualifiers.join(', ')}]" unless qualifiers.empty?
63
+
64
+ parts.join
65
+ end
66
+
67
+ def self.decompile_check(node)
68
+ opts = node.options || {}
69
+ line = "- **Check** (`#{node.name}`)"
70
+ line += ": #{opts[:condition]}" if opts[:condition]
71
+ line
72
+ end
73
+
74
+ def self.decompile_plan(node)
75
+ opts = node.options || {}
76
+ steps = opts[:steps] || []
77
+ step_str = steps.map(&:to_s).join(" -> ")
78
+ "- **Workflow** (`#{node.name}`): #{steps.size} steps — #{step_str}"
79
+ end
80
+
81
+ def self.decompile_tool_call(node)
82
+ opts = node.options || {}
83
+ line = "- **Tool Call** (`#{node.name}`)"
84
+ line += ": `#{opts[:command]}`" if opts[:command]
85
+ line
86
+ end
87
+
88
+ def self.decompile_semantic_reasoning(node)
89
+ opts = node.options || {}
90
+ prompt = opts[:prompt] || "(no prompt)"
91
+ "- **Human Judgment Required** (`#{node.name}`): #{prompt}"
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../skill_contexts'
4
+
5
+ module KairosMcp
6
+ module DslAst
7
+ # Drift item representing a single discrepancy between content and definition
8
+ DriftItem = Struct.new(:direction, :severity, :description, :node_name, keyword_init: true)
9
+
10
+ # Report of all detected drifts for a skill
11
+ DriftReport = Struct.new(:skill_id, :items, :coverage_ratio, :timestamp, keyword_init: true) do
12
+ def drifted?
13
+ !items.empty?
14
+ end
15
+
16
+ def errors
17
+ items.select { |i| i.severity == :error }
18
+ end
19
+
20
+ def warnings
21
+ items.select { |i| i.severity == :warning }
22
+ end
23
+
24
+ def infos
25
+ items.select { |i| i.severity == :info }
26
+ end
27
+
28
+ def summary
29
+ {
30
+ total: items.size,
31
+ errors: errors.size,
32
+ warnings: warnings.size,
33
+ info: infos.size,
34
+ coverage_ratio: coverage_ratio
35
+ }
36
+ end
37
+ end
38
+
39
+ # Deterministic content <-> definition drift detection
40
+ # No LLM usage — keyword matching and structural analysis only.
41
+ # NOTE: Drift thresholds and policies may become a SkillSet in Phase 3.
42
+ class DriftDetector
43
+ # Keywords that indicate structural assertions in natural language content
44
+ ASSERTION_KEYWORDS = %w[must required always never shall mandatory].freeze
45
+
46
+ # Detect drift between content and definition layers
47
+ # @param skill [KairosMcp::SkillsDsl::Skill] the skill to analyze
48
+ # @return [DriftReport]
49
+ def self.detect(skill)
50
+ items = []
51
+ content = skill.content || ""
52
+ definition = skill.definition
53
+ content_lower = content.downcase
54
+
55
+ # No definition => no drift analysis possible
56
+ unless definition && definition.nodes && !definition.nodes.empty?
57
+ return DriftReport.new(
58
+ skill_id: skill.id,
59
+ items: [],
60
+ coverage_ratio: nil,
61
+ timestamp: Time.now.iso8601
62
+ )
63
+ end
64
+
65
+ # Check 1: definition nodes reflected in content (definition_orphaned)
66
+ covered_count = 0
67
+ definition.nodes.each do |node|
68
+ if node_reflected_in_content?(node, content_lower)
69
+ covered_count += 1
70
+ else
71
+ items << DriftItem.new(
72
+ direction: :definition_orphaned,
73
+ severity: :warning,
74
+ description: "Definition node '#{node.name}' (#{node.type}) has no corresponding mention in content",
75
+ node_name: node.name
76
+ )
77
+ end
78
+ end
79
+
80
+ # Check 2: content assertions not covered by definition (content_uncovered)
81
+ uncovered = content_assertions_not_covered(content, definition)
82
+ uncovered.each do |assertion|
83
+ items << DriftItem.new(
84
+ direction: :content_uncovered,
85
+ severity: :info,
86
+ description: "Content assertion \"#{assertion}\" not covered by any definition node",
87
+ node_name: nil
88
+ )
89
+ end
90
+
91
+ # Coverage ratio: proportion of definition nodes reflected in content
92
+ total_nodes = definition.nodes.size
93
+ ratio = total_nodes > 0 ? covered_count.to_f / total_nodes : 1.0
94
+
95
+ DriftReport.new(
96
+ skill_id: skill.id,
97
+ items: items,
98
+ coverage_ratio: ratio.round(2),
99
+ timestamp: Time.now.iso8601
100
+ )
101
+ end
102
+
103
+ private
104
+
105
+ # Check if a definition node's name or key options appear in the content
106
+ def self.node_reflected_in_content?(node, content_lower)
107
+ # Convert node name from snake_case to words for matching
108
+ name_words = node.name.to_s.split('_')
109
+
110
+ # Check if any name word appears in content
111
+ name_match = name_words.any? { |word| content_lower.include?(word.downcase) }
112
+ return true if name_match
113
+
114
+ # Also check key option values
115
+ opts = node.options || {}
116
+ opts.each_value do |v|
117
+ next unless v.is_a?(String)
118
+ return true if content_lower.include?(v.downcase)
119
+ end
120
+
121
+ false
122
+ end
123
+
124
+ # Find content lines with assertion keywords not covered by any definition node
125
+ def self.content_assertions_not_covered(content, definition)
126
+ uncovered = []
127
+ node_keywords = collect_node_keywords(definition)
128
+
129
+ content.each_line do |line|
130
+ stripped = line.strip
131
+ next if stripped.empty?
132
+ next if stripped.start_with?('#', '|', '-') # Skip headers, tables, list markers that are structural
133
+
134
+ # Check if this line contains an assertion keyword
135
+ line_lower = stripped.downcase
136
+ has_assertion = ASSERTION_KEYWORDS.any? { |kw| line_lower.include?(kw) }
137
+ next unless has_assertion
138
+
139
+ # Check if any definition node keyword appears in this line
140
+ covered = node_keywords.any? { |kw| line_lower.include?(kw) }
141
+ unless covered
142
+ # Truncate long lines
143
+ display = stripped.length > 80 ? "#{stripped[0..77]}..." : stripped
144
+ uncovered << display
145
+ end
146
+ end
147
+
148
+ uncovered
149
+ end
150
+
151
+ # Collect searchable keywords from all definition nodes
152
+ def self.collect_node_keywords(definition)
153
+ keywords = []
154
+
155
+ definition.nodes.each do |node|
156
+ # Add name parts
157
+ node.name.to_s.split('_').each { |w| keywords << w.downcase }
158
+
159
+ # Add option values that are strings
160
+ (node.options || {}).each_value do |v|
161
+ case v
162
+ when String
163
+ v.split(/\s+/).each { |w| keywords << w.downcase if w.length > 3 }
164
+ when Symbol
165
+ keywords << v.to_s.downcase
166
+ end
167
+ end
168
+ end
169
+
170
+ keywords.uniq
171
+ end
172
+ end
173
+ end
174
+ end