Module: Parse::Agent::MetadataAudit

Extended by:
MetadataAudit
Included in:
MetadataAudit
Defined in:
lib/parse/agent/metadata_audit.rb

Overview

Boot-time / on-demand audit of agent metadata declarations across the application’s Parse::Object subclasses. Surfaces the gaps that silently degrade an LLM’s experience of the schema: classes with no ‘agent_description`, properties on the allowlist with no `_description:`, and `agent_fields` entries that don’t resolve to known wire columns.

Returns structured data so callers can wire it into a boot warning, a CI gate, or a Rake task. ‘print_summary` is a convenience for interactive use (rails console, scripts).

Examples:

Programmatic use

audit = Parse::Agent.
if audit[:missing_class_descriptions].any?
  warn "Classes without descriptions: #{audit[:missing_class_descriptions]}"
end

Interactive use

Parse::Agent::MetadataAudit.print_summary

Constant Summary collapse

ALWAYS_PRESENT_FIELDS =

System/system-adjacent fields that are always present on every Parse class and don’t benefit from ‘_description:`. Excluded from the missing-field-descriptions report.

%i[
  object_id objectId
  created_at createdAt
  updated_at updatedAt
  acl ACL
].freeze

Instance Method Summary collapse

Instance Method Details

#auditHash

Run the audit and return structured findings.

Returns:

  • (Hash)
    • :classes_audited [Integer] — number of classes inspected

    • :visible_classes_declared [Boolean] — whether the app uses opt-in ‘agent_visible` mode

    • :missing_class_descriptions [Array<String>] — Parse class names with no ‘agent_description`

    • :missing_field_descriptions [Hash<String, Array<Symbol>>] —class name -> property symbols missing ‘_description:`. When a class declares `agent_fields`, only allowlisted properties are counted; otherwise all declared properties.

    • :unresolvable_allowlist_entries [Hash<String, Array<Symbol>>] —‘agent_fields` entries that don’t appear in the class’s ‘field_map` (likely typos that 4.2.1’s wire-name translation will silently miss).

    • :canonical_filter_summary [Hash<String, Hash>] — per-class declared canonical filters, surfaced so the auditor can see which classes apply silent row-level predicates by default.



57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/parse/agent/metadata_audit.rb', line 57

def audit
  classes = audit_target_classes

  result = {
    classes_audited: classes.size,
    visible_classes_declared: Parse::Agent::MetadataRegistry.has_visible_classes?,
    missing_class_descriptions: [],
    missing_field_descriptions: {},
    unresolvable_allowlist_entries: {},
    canonical_filter_summary: {},
  }

  classes.each do |klass|
    name = parse_class_name_for(klass)
    next if name.nil?

    # Skip classes flagged agent_hidden — they're intentionally
    # opaque to the agent surface, and we shouldn't pretend the
    # missing description on them is a gap.
    next if klass.respond_to?(:agent_hidden?) && klass.agent_hidden?

    # Skip Parse system classes (`_`-prefixed parse_class names:
    # `_User`, `_Role`, `_Session`, `_Installation`, `_Product`,
    # `_Audience`). These are framework-supplied by parse-stack and
    # don't benefit from userland-authored agent_description — the
    # SDK is responsible for documenting them, not the application.
    # Without this skip, every app that doesn't opt into
    # `agent_visible` mode sees the system classes flooding
    # `missing_class_descriptions`, which discourages adoption of
    # the audit tool. Apps that DO want to document their system
    # classes can still call `agent_description` on `Parse::User`
    # etc. — the skip only suppresses the "missing" reports, not
    # the legitimate ones.
    next if name.to_s.start_with?("_")

    if klass.respond_to?(:agent_description) && klass.agent_description.nil?
      result[:missing_class_descriptions] << name
    end

    missing_fields = missing_field_descriptions_for(klass)
    result[:missing_field_descriptions][name] = missing_fields if missing_fields.any?

    unresolvable = unresolvable_allowlist_entries_for(klass)
    result[:unresolvable_allowlist_entries][name] = unresolvable if unresolvable.any?

    if klass.respond_to?(:agent_canonical_filter_for_apply) &&
       (cf = klass.agent_canonical_filter_for_apply) &&
       cf.any?
      result[:canonical_filter_summary][name] = cf.dup
    end
  end

  result[:missing_class_descriptions].sort!
  result
end

#audit_target_classesArray<Class>

Resolve the set of classes to audit.

When the application has opted into ‘agent_visible` mode, that registry IS the canonical list — the developer has explicitly said “these are the agent-facing classes.” Otherwise fall back to every Parse::Object subclass currently loaded (back-compat mode).

Returns:



186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/parse/agent/metadata_audit.rb', line 186

def audit_target_classes
  if Parse::Agent::MetadataRegistry.has_visible_classes?
    Parse::Agent::MetadataRegistry.visible_classes
  else
    # `Parse::Object.descendants` is the same iteration path used by
    # `Parse::Model.find_class` to resolve a Parse class name to a
    # Ruby class. Walks every loaded subclass without going through
    # the find_class cache (which raises NameError on miss and would
    # corrupt the audit's "what's declared" view).
    Parse::Object.descendants.select do |klass|
      klass.respond_to?(:parse_class) && klass.parse_class
    end
  end
end

#missing_field_descriptions_for(klass) ⇒ Object

Build the list of property symbols on a class that have no ‘_description:` declaration. When `agent_fields` is declared, the check is scoped to the allowlist (those are the agent-visible fields and the ones the LLM will see); otherwise it covers every declared property on the class.

Excludes ALWAYS_PRESENT_FIELDS (the four system columns) since those don’t benefit from per-property descriptions.



217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
# File 'lib/parse/agent/metadata_audit.rb', line 217

def missing_field_descriptions_for(klass)
  return [] unless klass.respond_to?(:property_descriptions)
  return [] unless klass.respond_to?(:field_map)

  described = klass.property_descriptions.keys.map(&:to_sym).to_set
  declared_properties = klass.field_map.keys.map(&:to_sym)

  candidates =
    if klass.respond_to?(:agent_field_allowlist) && klass.agent_field_allowlist.any?
      klass.agent_field_allowlist.map(&:to_sym)
    else
      declared_properties
    end

  candidates - described.to_a - ALWAYS_PRESENT_FIELDS
end

#parse_class_name_for(klass) ⇒ Object

The Parse-side class name for a Ruby class, or nil when the class isn’t a normal Parse::Object subclass (defensive — every entry in audit_target_classes should pass this).



204
205
206
207
# File 'lib/parse/agent/metadata_audit.rb', line 204

def parse_class_name_for(klass)
  return nil unless klass.respond_to?(:parse_class)
  klass.parse_class
end

Print a human-readable summary to the given IO (defaults to $stdout). The structured data from #audit is the source of truth; this is a convenience for interactive sessions.

Parameters:

  • io (IO) (defaults to: $stdout)

    destination (default $stdout)

Returns:

  • (Hash)

    the audit findings (same shape as #audit)



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# File 'lib/parse/agent/metadata_audit.rb', line 119

def print_summary(io: $stdout)
  data = audit

  io.puts "Parse::Agent metadata audit"
  io.puts "=" * 40
  io.puts "Classes audited: #{data[:classes_audited]} " \
          "(#{data[:visible_classes_declared] ? "agent_visible mode" : "all-subclasses fallback"})"
  io.puts

  missing_classes = data[:missing_class_descriptions]
  io.puts "Missing class descriptions (#{missing_classes.size}):"
  if missing_classes.empty?
    io.puts "  (none)"
  else
    missing_classes.each { |n| io.puts "  - #{n}" }
  end
  io.puts

  missing_fields = data[:missing_field_descriptions]
  total_missing_fields = missing_fields.values.sum(&:size)
  io.puts "Missing field descriptions (#{total_missing_fields} across #{missing_fields.size} classes):"
  if missing_fields.empty?
    io.puts "  (none)"
  else
    missing_fields.sort.each do |class_name, fields|
      io.puts "  #{class_name} (#{fields.size}):"
      io.puts "    #{fields.map(&:to_s).join(", ")}"
    end
  end
  io.puts

  unresolvable = data[:unresolvable_allowlist_entries]
  io.puts "Unresolvable allowlist entries:"
  if unresolvable.empty?
    io.puts "  (none)"
  else
    unresolvable.sort.each do |class_name, entries|
      io.puts "  #{class_name}: #{entries.map(&:to_s).join(", ")}"
    end
  end
  io.puts

  filters = data[:canonical_filter_summary]
  io.puts "Canonical filters declared (#{filters.size}):"
  if filters.empty?
    io.puts "  (none)"
  else
    filters.sort.each do |class_name, filter|
      io.puts "  #{class_name}: #{filter.inspect}"
    end
  end

  data
end

#unresolvable_allowlist_entries_for(klass) ⇒ Object

‘agent_fields` entries that don’t resolve to a known property on the class. These would silently miss after the 4.2.1 wire-name translation — the symbol would columnize to a column the schema doesn’t carry, and the filter would strip nothing.



238
239
240
241
242
243
244
245
246
# File 'lib/parse/agent/metadata_audit.rb', line 238

def unresolvable_allowlist_entries_for(klass)
  return [] unless klass.respond_to?(:agent_field_allowlist)
  allowlist = klass.agent_field_allowlist
  return [] if allowlist.empty?
  return [] unless klass.respond_to?(:field_map)

  known = klass.field_map.keys.map(&:to_sym).to_set
  allowlist.map(&:to_sym).reject { |sym| known.include?(sym) }
end